Skip to content

ThreadLocal<T>

ThreadLocal<T> is the compiler-owned per-OS-thread storage handle. It is the right choice for mutable state that must not be shared between worker threads. The handle is immutable and copyable; each OS thread lazily constructs and owns its own T.

No import is required. If a declaration shadows ThreadLocal, use the canonical std.ThreadLocal<T> spelling.

Constructing A Key

A key is constructed once, as a module-level let:

Zynx
fn initial_counter() -> i32 {
	return 10;
}

let counter = ThreadLocal<i32>(initial_counter);

fn main() {
	let before = counter.read((value: i32) -> i32 {
		return value;
	});
	assert before == 10;
}

The initializer is a named function or a direct noncapturing closure. It takes no arguments, is synchronous and infallible, and returns exactly T. It is not run while the module is loaded. The first access on each OS thread invokes it once for that thread.

For a move-only payload, the factory transfers ownership with the normal return-role spelling ^T; the descriptor type remains ThreadLocal<T>:

Zynx
struct State {
	var count: i32,
}

let state = ThreadLocal<State>(() -> ^State {
	return State { count: 0 };
});

ThreadLocal<T>(...) is not a general runtime allocation. Construction is therefore rejected in local code, in a module-level const or var, and when nested inside another initializer. A module-level key may be exported, copied, passed to a function, stored in an ordinary Zynx value, or captured by worker code.

Scoped Access

read lends the current thread's payload through a shared bare T parameter. update lends it through an exclusive &T parameter:

Zynx
let hits = ThreadLocal<i32>(() -> i32 {
	return 0;
});

fn record_hit() -> i32 {
	hits.update((value: &i32) -> void {
		value = *value + 1;
	});
	return hits.read((value: i32) -> i32 {
		return value;
	});
}

fn main() {
	assert record_hit() == 1;
}

Nested read calls for the same key are allowed. update requires exclusive access; entering it while any read or update for that key is active, or entering read from an active update, traps deterministically. This dynamic check also covers indirect recursion that static borrow checking cannot see.

The callback result is the result of the access call. A throwing callback propagates its own error set, and the access guard is released on both success and error paths. Mutations completed before an error are not rolled back.

The payload borrow is valid only during the callback. It cannot be returned, stored globally, hidden in an aggregate or closure, converted into escaping pointer provenance, or captured by a Future or Task. Access callbacks are required to be synchronous. This is a semantic guarantee, not a temporary implementation limitation: a TLS borrow always begins and ends on the same OS thread, so an access callback cannot contain await or otherwise suspend.

Thread Lifetime

Each OS thread has the states uninitialized, initializing, live, and dropped for each key. Recursive initialization, including a cycle between two keys, traps instead of exposing a partially initialized value. A thread that never accesses a key neither constructs nor drops its payload.

If T requires cleanup, its drop is registered only after initialization has completed. Payloads are dropped at thread exit in reverse order of successful initialization. The state becomes dropped before user cleanup runs, so re-entering the same key from its drop traps rather than causing a use-after-drop. The main thread's payload is dropped during program shutdown.

ThreadLocal<T> is OS-thread-local, not task-local. An async task may resume on a different worker after await, so two separate accesses by the same task can observe different per-thread payloads. Allowing an access callback to cross that boundary would make its borrow refer to the payload of the worker it left. Task-scoped state therefore requires a separate task-local abstraction rather than an asynchronous form of ThreadLocal.read or ThreadLocal.update.

Ownership And ABI

The descriptor is allocation-free, opaque, Copy, and send-safe even when T itself is not send-safe: copying it transfers only the key identity, never a thread's payload. Its representation is not a public layout guarantee.

ThreadLocal<T> has no C ABI spelling and is rejected in extern arguments, returns, callback signatures, C-layout fields, and bindgen output. Wrap access behind an ordinary exported function when C code needs to reach thread-local state.

There is no eager get, take, reset, clear, or manual drop operation.

See Concurrency And Parallelism for worker and async semantics, Types And Values for global initialization, and Memory And Unsafe Code for borrow and FFI rules.

Released under the MIT License.