← board

Thread-local storage still shares one copy off x86-64

Why this ticket exists at all

[[bug-c-__thread-is-accepted-and-silently-ignored-so-thread-local-storage-is-shared]] was closed by giving __thread real per-thread storage on x86-64 scalars. That fix is a strict improvement with no regression anywhere — every program that compiled before still compiles, and every program that was correct before is still correct.

"No regression anywhere" is true and it is not the whole finding. An exculpation needs an owner for the residual question, and the residual is a population that is still served a wrong answer. This ticket is that owner, so the claim does not end at a true sentence that reads like an all-clear.

The population still affected

A multi-threaded C program using __thread, where any of these holds:

condition why the mechanism cannot serve it
target is not x86-64 the per-thread block is installed by arch_prctl(ARCH_SET_GS) in the clone stub; aarch64/arm32 have a readable thread register (tpidr_el0, tpidruro) and no way to SET one yet
the variable is an array several paths reach an array through its SYMBOL (bounds, handles, element type) and a thread-local reference is rewritten to a pointer dereference before they see it
the variable is a managed or aggregate type needs per-thread init/final at thread start and exit; no thread hook exists
--emit-obj / --shared no ELF entry point, so nothing installs the block
function scope (static __thread int t; inside a body) the fix hooks ParseCGlobalVarDecl; a local declaration never reaches it. Warns since 2026-09-19 (TLSREFUSE_FUNCSCOPE); still shares.

In every one of these the declaration compiles to one shared .bss object, exactly as it did before the fix, and a warning naming the specific reason is the only thing between that program and a wrong answer.

FIXED 2026-09-19 — THE FUNCTION-SCOPE ROW WAS THE ONE THAT WARNED NOTHING AT ALL, AND NOW WARNS. It gained TLSREFUSE_FUNCSCOPE, a sixth reason that is not an answer from the allocator but the absence of a question: the block-scope storage-class loop consumed the qualifier and recorded only static, so no idx and no reason ever existed. The sharing is unchanged — see feature-c-a-function-scope-thread-local-gets-real-per-thread-storage for why giving it real storage is blocked on the area sizing rather than merely undone. The paragraph below is the original finding and is kept as the record of what was true before; read it in the past tense.

THE FUNCTION-SCOPE ROW WAS THE ONE THAT WARNED NOTHING AT ALL. The fix hooks the file-scope declaration parser, so a static __thread int t; inside a body is never seen by it — and the warning it replaced lived in the TOP-LEVEL walk, so that shape never warned before this change either. No regression, and it is now the only silent member of the family, which is exactly the property the parent ticket was filed about.

SCOPING CORRECTED 2026-09-16 (frankuser), and the correction matters because the old wording misrouted. This ticket's population is "multi-threaded C off x86-64", and function scope was listed inside it — but function scope fails on x86-64, with one thread, so a reader filtering on either qualifier would skip it. That was true when written for a different reason than assumed: what made it fail single-threaded was not thread-local storage at all, it was the block-scope static being silently discarded when __thread stood between it and the type, which produced a wrong value with no threads anywhere near it. That bug is fixed — [[bug-c-a-block-scope-static-is-silently-dropped-when-a-thread-storage-class-precedes-the-type]].

So what remains at function scope is SHARING ONLY, and it now genuinely does belong to this ticket's population: storage is correct, static __thread int t; counts 1, 2, 3 exactly as gcc does single-threaded, and every thread still uses the one copy. Whoever takes this should still do function scope FIRST — it is the smallest of the five and the only one where a user gets no signal at all.

Why it warns instead of refusing, and why that is not being revisited here

__thread compiles today. For a single-threaded program one copy shared IS one copy per thread, so those programs are correct, and refusing would break working programs to protect broken ones. The parent ticket prescribed refusal — written before x86-64 worked, i.e. a prediction — and re-derived against the built thing the prescription does not survive. Pascal's threadvar refuses because it has never compiled at any scope and so has no population to break.

That reasoning is settled and this ticket is not a request to revisit it. What is open is closing the gap, not changing what happens while it is open.

What would close it

Any one of these shrinks the population; none is required for the others:

  1. A settable thread register on aarch64/arm32 — the blocker named by the refusal text itself. Closes the largest slice.
  2. Arrays — the TLS user area already fits one; the work is teaching the symbol-reaching paths about a rewritten reference.
  3. --emit-obj/--shared — needs an entry-point-independent way to install the block, and interacts with the GS-vs-psABI fork: GS-relative serves pxx-compiled code and does NOT interoperate with TLS relocations in a gcc-built object.

Acceptance

Assert the RELATION, not a per-target constant — two threads writing and reading the same __thread variable must never see each other's value — so a row carries no expected width and cannot pass by matching a default.

A row per target with an object writer, because TLS is emitted per backend: one green on x86-64 closes nothing. test/c_thread_local_is_per_thread.c is the x86-64 row and is the shape to copy; its six assertions and, in particular, which two of them (zeroed-on-entry, main-copy) survive serial execution are documented in the file.