Skip to content

Can a thread borrow a local variable?

Level: 201 · anyone arriving at Rust from POSIX threads, or wondering what the 'static on thread::spawn is doing there

One line: Eight numbers on a function's stack, summed by two threads that read them in place — the data has to outlive the threads, and the three languages answer that in three different places: C leaves it to the two pthread_join calls you remembered to write and says nothing either way, Rust refuses the program unless the threads are joined inside the block that owns the data, and Go sidesteps the question by moving the data off the stack.

The question

A function has eight numbers in a local array and wants two threads to add up one half each. Nothing is shared for writing, so none of chapter 02's problems arise; the only question is whether the numbers are still there when the threads read them. A stack frame is the shortest-lived storage a program has, and a thread is the least predictable thing that could be reading it.

There are only three ways to be safe, and each language picks one:

  1. Outlive it by construction — make sure the frame cannot go away until the threads are done. This is what pthread_join does, and it is a promise the programmer makes.
  2. Check the promise — have the compiler refuse the program unless it can see that the threads end first. This is thread::scope, and it is why thread::spawn demands 'static.
  3. Remove the premise — put the data somewhere that outlives every frame, and let something else decide when it dies. This is Go's escape analysis with a garbage collector behind it.
How a thread gets at the local What makes it sound What happens when it is not
Rust thread::scope, and the closures borrow samples with no lifetime written anywhere scope does not return until every thread started in it has been joined, so the borrow cannot outlast the frame thread::spawn instead: error[E0597], argument requires that samples is borrowed for 'static
Go the goroutines close over samples — allowed, and nothing is written differently the compiler moves samples to the heap and the collector keeps it alive as long as anything can reach it it cannot be wrong about the lifetime; go build -gcflags=-m prints the decision it made instead
C pthread_create(&a, NULL, sum_half, &left) — a void *, which carries neither the type nor the lifetime the two pthread_join calls before the return, and nothing else in the program or the toolchain it compiles with no diagnostic, and reading the dead frame is undefined behaviour

C++ erases the lifetime as C does while keeping the type; Java and Python answer as Go does, having no stack-allocated array to lend in the first place. (Not machine-checked here.)

Rust

Verified output of lend_local_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

left half 9 + right half 22
the total is 31

samples is an ordinary local array, left and right are ordinary slices of it, and the two closures are ordinary borrows — no Arc, no clone, no lifetime annotation. thread::scope ↗ is what makes that legal: the closures it accepts are bound by 'scope rather than by 'static, and scope joins every thread that has not been joined before it returns. The borrow therefore cannot outlive the frame, and the compiler can see so without being told.

Change scope to spawn and nothing else:

Verified output of lend_local_spawn_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ rustc --edition 2024 spawned.rs
error[E0597]: `samples` does not live long enough
    ...argument requires that `samples` is borrowed for `'static`
rustc exit status 1
no binary was built

thread::spawn ↗ hands back a JoinHandle ↗ that the caller may drop, so the thread may still be running when the frame is gone; the bound that follows from that is 'static, and the error says exactly that. Note what it does not say. This program joins both handles two lines later, so the threads demonstrably end first — and the rule is about the type of spawn, not about the path through the program, so the argument is never made. Who waits when main returns? shows the dropped handle detaching a thread, which is the behaviour this bound exists to make safe.

lend_local_rs.rs · lend_local_spawn_rs_sh.sh

lend_local_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.

//! Eight numbers on one function's stack, summed by two threads that borrow
//! halves of them. Nothing is cloned, nothing is reference-counted, and no
//! lifetime is written down: `thread::scope` cannot return until both threads
//! have finished, which is what makes borrowing a local sound.
//!
//!   rustc --edition 2024 lend_local_rs.rs -o lend_local_rs && ./lend_local_rs

use std::thread;

fn sum_on_two_threads() -> i64 {
    let samples: [i64; 8] = [3, 1, 4, 1, 5, 9, 2, 6]; // on this function's stack
    let (left, right) = samples.split_at(4);

    let (a, b) = thread::scope(|s| {
        let a = s.spawn(|| left.iter().sum::<i64>()); // borrows `samples`
        let b = s.spawn(|| right.iter().sum::<i64>());
        (a.join().unwrap(), b.join().unwrap())
    }); // `scope` has joined both threads; only now may this frame go away

    println!("left half {a} + right half {b}");
    a + b
}

fn main() {
    println!("the total is {}", sum_on_two_threads());
}

lend_local_spawn_rs_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The same two-thread sum written with thread::spawn instead of thread::scope.
# Nothing else changes, and it does not build: a spawned thread may outlive the
# function that started it, so what it captures must live for 'static, and eight
# numbers on a stack frame do not. The diagnostic draws arrows at columns that
# can move between compiler versions, so this script keeps the error's first
# line, the line that names the bound it could not meet, and rustc's exit
# status. The note pointing into std's own source is left out: it prints the
# source line only where the rust-src component is installed.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/spawned.rs" <<'RS'
use std::thread;

fn sum_on_two_threads() -> i64 {
    let samples: [i64; 8] = [3, 1, 4, 1, 5, 9, 2, 6];
    let (left, right) = samples.split_at(4);

    let a = thread::spawn(|| left.iter().sum::<i64>());
    let b = thread::spawn(|| right.iter().sum::<i64>());

    a.join().unwrap() + b.join().unwrap()
}

fn main() {
    println!("the total is {}", sum_on_two_threads());
}
RS

say() { printf '$ %s\n' "$*"; }

say 'rustc --edition 2024 spawned.rs'
(cd "$dir" && rustc --edition 2024 spawned.rs 2>diagnostic.txt)
status=$?
grep '^error\[' "$dir/diagnostic.txt"
sed -n 's/.*argument requires that \(.*\)$/    ...argument requires that \1/p' "$dir/diagnostic.txt"
echo "rustc exit status $status"
if [ -e "$dir/spawned" ]; then
    echo "and yet a binary was built"
else
    echo "no binary was built"
fi

Go

Verified output of lend_local_go_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build -gcflags=-m -o lend lend.go
moved to heap: a
moved to heap: b
moved to heap: samples
moved to heap: wg
$ ./lend
left half 9 + right half 22
the total is 31

Go builds the same program without a word of complaint, and it is right to: samples is not on the stack by the time the goroutines read it. -gcflags=-m asks the compiler to report its escape analysis, and moved to heap: samples is the whole answer — a local whose address outlives its frame is not kept in that frame. a, b and the WaitGroup move for the same reason.

This is not a concurrency feature. It is the same analysis that lets a Go function return a pointer to a local, and goroutines are one more way for an address to escape. The price is the allocation that the other two columns avoid by making the programmer prove the lifetime instead — plus a collector that has to establish the object is unreachable before the memory comes back. What Go buys with it is that the question on this page cannot be answered wrongly.

lend_local_go_sh.sh

lend_local_go_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The same two-goroutine sum in Go, where a goroutine may outlive the function
# that started it and borrowing a local is allowed anyway. The compiler settles
# it by moving the variable: `go build -gcflags=-m` reports what it decided.
# Its lines are prefixed with file and line numbers, which move whenever the
# program is edited, so this script keeps the decisions and drops the prefixes.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/lend.go" <<'GO'
package main

import (
    "fmt"
    "sync"
)

func sumOnTwoGoroutines() int {
    samples := [8]int{3, 1, 4, 1, 5, 9, 2, 6} // a local, as in the other two
    left, right := samples[:4], samples[4:]

    var a, b int
    var wg sync.WaitGroup
    wg.Go(func() {
        for _, n := range left {
            a += n
        }
    })
    wg.Go(func() {
        for _, n := range right {
            b += n
        }
    })
    wg.Wait()

    fmt.Println("left half", a, "+ right half", b)
    return a + b
}

func main() {
    fmt.Println("the total is", sumOnTwoGoroutines())
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build -gcflags=-m -o lend lend.go'
(cd "$dir" && "${GO:-go}" build -gcflags=-m -o lend lend.go 2>escape.txt) || exit 1
sed -n 's/^.*: \(moved to heap: .*\)$/\1/p' "$dir/escape.txt" | sort

say './lend'
(cd "$dir" && ./lend)

C

Verified output of lend_local_c.c — regenerated by tools/run_examples.py, never hand-typed.

left half 9 + right half 22
the total is 31

The same answer, and everything holding it up is in the source. pthread_create's fourth parameter is a void *: it carries no type, so sum_half casts it back by agreement, and it carries no lifetime, so nothing anywhere relates the life of &left to the life of the thread. What makes this program correct is that the two pthread_join calls sit above the return — the same guarantee thread::scope gives, written as a convention instead of a type.

Move one of them below, and the convention is all that breaks:

Verified output of lend_local_dangling_c_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cc -std=c17 -Wall -Wextra -pedantic -pthread dangling.c -o dangling
the compiler printed nothing
cc exit status 0
a binary was built, and this script does not run it

A function that starts a thread over its own locals and returns the pthread_t for someone else to join is the ordinary C spelling of thread::spawn, and it is the Rust program that would not build. Here it builds with nothing said, under every warning this library uses. The compiler is not being lax: pthread_create ↗ is an ordinary function taking an ordinary pointer, and there is no rule in C that a pointer argument must outlive the call.

The program is not run above, because reading a dead frame is undefined behaviour ↗ and what it prints is not a fact about the program. AddressSanitizer ↗ does find it, and has to be asked, and its report carries addresses and an exit status that the two runtimes do not agree on — so, as real runs:

Real runs — demo/use_after_return.sh, built -fsanitize=address -g -O1, 10 runs each, 2026-09-22
Apple clang 21.0.0, x86-64 Mac, macOS 26
  10 of 10 runs reported an AddressSanitizer stack error, of which the first was:
    ERROR: AddressSanitizer: stack-use-after-scope
    WRITE of size 8 at 0x… thread T1
    is located in stack of thread T0 at offset 144 in frame
      'left' (line 27) <== Memory access at offset 144 is inside this variable
    SUMMARY: AddressSanitizer: stack-use-after-scope dangling.c:18 in sum_half
  exit statuses seen: 134            (SIGABRT)
  what the program printed:

GCC 14.4.0, Docker gcc:14
  10 of 10 runs reported an AddressSanitizer stack error, of which the first was:
    ERROR: AddressSanitizer: stack-use-after-scope
    WRITE of size 8 at 0x… thread T1
    is located in stack of thread T0 at offset 80 in frame
      'left' (line 27) <== Memory access at offset 80 is inside this variable
    SUMMARY: AddressSanitizer: stack-use-after-scope dangling.c:18 in sum_half
  exit statuses seen: 1
  what the program printed:

Ten runs out of ten on each, and the report names the variable and the line: the write is h->sum = 0 in the thread, and the memory is left, in a frame belonging to another thread that has moved on. Both toolchains call it use after scope rather than use after return, because at -O1 they inlined start_summing into main — the frame is still there, and left's lifetime inside it has ended. Neither run printed joined: the process died before it got that far.

To run it yourself, from the lesson folder: bash demo/use_after_return.sh, or bash demo/use_after_return.sh 30.

lend_local_c.c · lend_local_dangling_c_sh.sh

lend_local_c.c in full — pasted here by tools/run_examples.py from the file CI runs.

/* The same eight numbers on one function's stack, summed by two POSIX threads
 * that hold pointers into them. This program is correct, and every part of
 * saying so is the programmer's: the two pthread_join calls come before the
 * return, so the frame that `samples` lives in outlives both threads. Move
 * either join below the return and nothing here would say a word.
 *
 *   cc -std=c17 -Wall -Wextra -pedantic -pthread lend_local_c.c -o lend_local_c && ./lend_local_c
 */
#define _POSIX_C_SOURCE 200809L

#include <pthread.h>
#include <stdio.h>

struct half {
    const long *from;
    int n;
    long sum;
};

static void *sum_half(void *arg) {
    struct half *h = arg; /* the void * was a struct half *, because we say so */
    h->sum = 0;
    for (int i = 0; i < h->n; i++) {
        h->sum += h->from[i];
    }
    return NULL;
}

static long sum_on_two_threads(void) {
    long samples[8] = {3, 1, 4, 1, 5, 9, 2, 6}; /* on this function's stack */
    struct half left = {samples, 4, 0};
    struct half right = {samples + 4, 4, 0};

    pthread_t a, b;
    pthread_create(&a, NULL, sum_half, &left);
    pthread_create(&b, NULL, sum_half, &right);
    pthread_join(a, NULL); /* both joined before... */
    pthread_join(b, NULL);

    printf("left half %ld + right half %ld\n", left.sum, right.sum);
    return left.sum + right.sum; /* ...this frame goes away */
}

int main(void) {
    printf("the total is %ld\n", sum_on_two_threads());
    return 0;
}

lend_local_dangling_c_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The C mirror of the Rust program that does not compile: a function starts a
# thread pointing at its own locals and returns the pthread_t, leaving the
# caller to join. By the time the thread reads, the frame it is reading has
# gone. Every warning this library builds with is on, and the compiler has
# nothing to say.
#
# The program is not run here. Reading a dead stack frame is undefined
# behaviour, so what it prints is not an answer key; demo/use_after_return.sh
# runs it under AddressSanitizer instead.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/dangling.c" <<'C'
#define _POSIX_C_SOURCE 200809L

#include <pthread.h>
#include <stdio.h>
#include <time.h>

struct half {
    const long *from;
    int n;
    long sum;
};

static void *sum_half(void *arg) {
    struct half *h = arg;
    /* A second's margin, so that the read below certainly happens after
       start_summing has returned. It is not synchronizing anything. */
    nanosleep(&(struct timespec){1, 0}, NULL);
    h->sum = 0;
    for (int i = 0; i < h->n; i++) {
        h->sum += h->from[i]; /* `from` points into a frame that is gone */
    }
    return NULL;
}

static pthread_t start_summing(void) {
    long samples[8] = {3, 1, 4, 1, 5, 9, 2, 6};
    struct half left = {samples, 4, 0};

    pthread_t a;
    pthread_create(&a, NULL, sum_half, &left);
    return a; /* `samples` and `left` die here; the thread still holds them */
}

int main(void) {
    pthread_t a = start_summing();
    pthread_join(a, NULL);
    printf("joined\n");
    return 0;
}
C

say() { printf '$ %s\n' "$*"; }

say 'cc -std=c17 -Wall -Wextra -pedantic -pthread dangling.c -o dangling'
(cd "$dir" && "${CC:-cc}" -std=c17 -Wall -Wextra -pedantic -pthread dangling.c -o dangling 2>diagnostic.txt)
status=$?
if [ -s "$dir/diagnostic.txt" ]; then
    echo "the compiler printed:"
    cat "$dir/diagnostic.txt"
else
    echo "the compiler printed nothing"
fi
echo "cc exit status $status"
if [ -x "$dir/dangling" ]; then
    echo "a binary was built, and this script does not run it"
fi

What to do

  • In Rust, reach for thread::scope before reaching for Arc. Sharing a local for the duration of a block is the common case, and it needs neither an allocation nor a reference count. thread::spawn and 'static are for threads that genuinely outlive the frame.
  • Read 'static as "may outlive any caller", not as "lives for the whole program". The error on this page is not about the program's lifetime; it is about spawn promising nothing regarding when the thread ends.
  • In C, write the join in the same function as the data. A function that starts a thread over its own locals and returns a pthread_t has made its caller responsible for something the caller cannot see.
  • Build the threaded parts once under -fsanitize=address. It costs a build and it answers the question the compiler was never asked.
  • In Go, know that the escape is the answer, not a defeat. -gcflags=-m is worth reading when the allocation matters; the alternative would have been this page's C column.

See also