← board

Thread-local storage: threads share one fs base

The finding

lib/rtl/palthread.pas creates threads with

PXX_CLONE_THREAD = $350F00;

Decoded, that is CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID. CLONE_SETTLS ($80000) is not among them. So every thread inherits the parent's fs base, and an fs:-relative slot is the same memory in every thread — worse than absent, because it looks like TLS and silently aliases.

Why it matters beyond one ticket

Anything wanting per-thread state today has exactly two options, and both are bad:

  1. gettid and index a table — a syscall per access. Acceptable at statement granularity (the --threadsafe I/O lock already does this per I/O statement) and hopeless on an allocator fast path, which is the point.
  2. Don't have per-thread state, which is where the runtime is now.

Concretely blocked:

Shape of the work

Set CLONE_SETTLS and pass a per-thread TLS block as clone's tls argument, allocated alongside the thread's stack in palthread. Per-arch:

Then a compiler-side accessor so runtime code can reach a slot without a syscall.

Do not derive per-thread identity from the stack pointer as a shortcut. It works until a thread's stack is reused or a handler runs on the sigaltstack — where SP is deliberately somewhere else entirely, which [[feature-signal-siginfo-ucontext]] item 3 just made a normal occurrence.

Gate

A per-thread slot that is genuinely distinct per thread under contention (the test_thread_heap shape: N threads write a unique tag and read it back), existing thread suite green, --threadsafe still correct on every target that accepts it, self-host byte-identical.

2026-08-20 — RESOLVED, and the prescribed remedy was the wrong one

The finding held; the fix in "Shape of the work" did not

The premise above is correct and is now measured rather than read off a constant: delete the per-thread install from test/test_tls_base.pas and four threads report ~39000 tag mismatches against each other, because clone does not reset fs and every child reads the parent's block.

What did not survive contact is the prescription — "Set CLONE_SETTLS and pass a per-thread TLS block as clone's tls argument". That would have meant a sixth __pxxclone argument, four IR_CLONE lowerings, four hand-emitted stub legs, a user_desc struct on i386, and the two other frontends' copies of the intrinsic.

arch_prctl(ARCH_SET_FS) acts on the CALLING thread. So a thread installs its own block as its first act and clone is not involved at all. Installing needs no compiler support whatsoever — it is __pxxrawsyscall(158, $1002, blk, ...), an ordinary syscall through machinery that already exists.

Only the READ side needed a compiler, and only because the x86-64 fs base is not readable as a register (rdfsbase requires CR4.FSGSBASE, not guaranteed). So the whole change is one no-operand intrinsic:

Net: ~40 lines across four files, one backend touched, versus the multi-arch clone surgery the ticket asked for. Same discipline as root-cause-over-microfix.md, running the other way for once — the ticket named a plausible mechanism and the smaller one was real.

The convention, and the trap in it

Slot 0 of the block holds the block's own address (what glibc and musl do, for exactly this reason). __pxxTlsBase is deliberately read-only and base-only: with the self-pointer in place, ordinary pointer arithmetic reaches every future per-thread field — arena, errno, exception stack, RNG — so there is never an intrinsic per field.

The trap the ticket warned about is NOT removed by this, it is relocated: installing is mandatory, and belongs in the launcher before user code runs. A thread that skips it does not read a null base you can test for — it reads the parent's block, i.e. the aliasing bug wearing a pointer that looks fine. The negative run above is that failure mode, deliberately provoked.

The "do not derive per-thread identity from the stack pointer" warning stands and was not needed: nothing here looks at SP.

x86-64 only, deliberately

aarch64 (tpidr_el0), arm32 (tpidruro) and i386 (gs) all have a readable thread register, but this runtime has no path that sets one — i386 in particular wants a struct user_desc rather than a raw base. So the frontend errors there at compile time, naming this ticket, rather than answering with the parent's block. Same call as __pxxSigNum and the si_code slice: a plausible wrong pointer is worse than a compile error, and threading is x86-64-only today anyway (the clone stub exists on four arches but --threadsafe gates the rest).

Test

test/test_tls_base.pas, wired into test-threads. Four threads, each installs its own block and re-reads its tag 20000× while the others churn. Four distinct assertions, each catching a different way to be wrong: blocks distinct (the aliasing bug), the parent's tag intact after the joins (children must not disturb it), __pxxTlsBase = the installed address (a wrong fs:[0] encoding would still return something), and the churn loop (a shared base can pass a post-join snapshot and still tear under contention). Run 10× consecutively, 0 errors — a threading change earns repetition rather than one pass. pinned rejects the program outright (undefined variable (__pxxTlsBase)), so it bites.

What this unblocks, and what is left

[[feature-threadsafe-heap-optimize]] moved to blocked/ on this ticket; the primitive it was waiting for now exists, so its per-thread-arena half is unblocked. But the remaining work there is lib/rtlpalthreadobj's launcher installing a block per TThread, and the allocator magazine itself — which is Track B's file-lane, not A's. This ticket delivers the compiler primitive and stops at the lane boundary.

Also still open and not addressed here: [[audit-shared-global-reentrancy-thread-safety]] (BSS_EXC_TOP and friends) now has a mechanism available, but moving those globals into TLS is its own ticket — every one of them is read on paths that run before any install could have happened, which is the same ordering problem in a harder place.

Docs: devdocs/dev/threading.md gained a "Thread-local storage (x86-64)" section.

Gate

make compiler/pascal26 (byte-identical fixedpoint, converged in 1 round) + tools/gate.sh quick + test_tls_base ×10 + the negative variant.

Log


Note appended 2026-09-01 (frankB) — read this before the tail above

Body left intact as the session record; this corrects one line and answers a question two sessions arrived here with.

1. One stale claim in "What this unblocks". It says "threading is x86-64-only today anyway (the clone stub exists on four arches but --threadsafe gates the rest)". That is false and was already false when written: --threadsafe has accepted x86-64, i386, aarch64 and arm32 since 07fee0844 (2026-07-06). It is the same assertion swept from five source sites by [[bug-a-threadsafe-is-x86-64-only-is-asserted-in-five-places-and-has-been-false-since-july]] (resolved 4eb58366c) — but that sweep covered compiler/** comments and devdocs/dev/threading*.md and never looked in devdocs/progress/done/. So the claim survives here, in the document pasparser_expr.inc's __pxxTlsBase Error sends every reader to. Verified today at emit.inc:434, ir_codegen_xtensa.inc:434 and the gate at lexer.inc:1291.

2. Why a compile-time Error cites a ticket that is done — the citation is precise, not stale. The confusion is that this ticket is named after a mechanism it did not use. TLS landed via arch_prctl(ARCH_SET_GS) in the clone stub (thread_emit.inc:86-88), which acts on the calling thread and so needed no clone flag: PXX_CLONE_THREAD is still $350F00 and still omits CLONE_SETTLS. The Error cites this ticket for the residue the section above records as not done — i386/aarch64/arm32 have a readable thread register and no way to SET one. Both halves are true at once; only the slug misleads, and it is load-bearing in five source files so it is not being renamed.

3. __pxxTlsBase is Pascal-reachable and nothing uses it yet. Those are two facts, and grep -rn TLS_SLOT compiler/builtin/ lib/rtl/ returning nothing proves only the second. It cost two sessions a wrong "a Pascal-reachable TLS accessor comes first" — retracted in [[feature-a-reentrant-heap-lock-and-per-thread-arenas]], which now carries the slot budget and the x86-64-only scope question.