GS-relative TLS vs the FS-relative psABI
The fork
__thread needs per-thread storage. pxx has a per-thread block and it is on
GS. Standard x86-64 ELF TLS is on FS. Which one do pxx's thread-locals
use, and what do we promise at the boundary?
What is already settled, and by whom
thread_emit.inc:142 chose GS deliberately and stated the reason in the code:
"GS, not fs: fs belongs to libc, and a pxx program may link one." That is a
good reason and this ticket is not reopening it for pxx-internal use. Measured
2026-09-06 (with a sentinel control that separates "base is 0" from "arch_prctl
never ran"), a pxx-native BeginThread thread gets a distinct non-zero GS base
while FS is 0 in both threads. The mechanism works.
The options
1. GS-relative, pxx-only. Thread-locals work in pxx-compiled code. An
--emit-obj object's __thread variables are NOT reachable by a gcc caller's
TLS relocations, and a gcc object's __thread is not reachable by ours. State
it as a documented boundary.
- cheapest, consistent with the choice already made, and needs no FS handling
- but
--emit-objis for linking into foreign programs, which is where the limitation lands hardest
2. FS-relative, psABI-conformant. Interoperates. Conflicts with the reason GS was chosen: a pxx program that links libc has FS owned by that libc, and the two would fight over it.
3. Both, by output mode. GS for a standalone pxx program, FS/psABI under
--emit-obj and --shared. Correct at both boundaries and the most work —
two relocation models and two accessor shapes.
Recommendation
Option 1 for now, with the boundary written down, and option 3 named as the
upgrade path if a real consumer appears. The deciding question is whether
anyone actually links a pxx object containing __thread into a gcc program —
if nobody does, 2 and 3 buy nothing and cost a fight with libc over FS.
What would settle it is a real consumer, not an argument.
A PROOF OF CONCEPT THAT FITS IN THE SLACK IS NOT A SMALLER VERSION OF THE FEATURE
Framing owed to frankA, who says they would have walked into it. The existing
per-thread block is exactly full — 144 slots, 0..12 taken, the heap magazine at
16..79 and 80..143 — leaving three. Three is enough to demonstrate
__thread working on one variable and nothing like enough for the feature,
and a demo is how a design gets ratified.
So a working one-variable proof would be evidence for the wrong proposition: it would show that GS-relative access works (true, and not the open question) while saying nothing about allocation, which is the part that has to change. Whoever implements this must size the area first and demonstrate second.
Same shape as the discriminating-fixture problem on the layout ticket next door:
{int a; double y} "works" under both candidate rules and therefore separates
neither. A fixture that fits the slack cannot test the thing the slack is
hiding.
And a second thing to rule on at the same time
A thread pxx did NOT create inherits the parent's GS base rather than
getting its own. Measured: glibc pthread_create gives main and child the
identical 4298F0, because GS base is inherited across clone and such a
thread never passes through pxx's stub.
So under any GS scheme, a foreign-created thread silently shares the creator's thread-locals. That is the hazard the clone stub's own comment cites as its reason for installing GS first — a valid-looking pointer into someone else's storage, not a null you could test for. Options are to detect it and refuse, to install lazily on first access, or to document it. Whichever way this fork goes, that question is part of it and should not be answered separately by whoever writes the code.
Blocks [[bug-c-__thread-is-accepted-and-silently-ignored-so-thread-local-storage-is-shared]].
RULED 2026-09-09, BY IMPLEMENTATION, IN THE RECOMMENDED DIRECTION
7a166c995 (frankH, Track P, feature-p-threadvar-is-not-supported-at-any-scope)
shipped Pascal threadvar on the GS block. That settles this fork as option
1 — GS-only with the boundary enforced — and it enforces it in code rather
than documenting it: --emit-obj and --shared are REFUSED
(AssignThreadVarStorage, pasparser_decl.inc), as is every target but
x86-64.
AND THE SHIPPED REASON IS SHARPER THAN THE ONE THIS TICKET ARGUED. I framed
the boundary as an interop limitation — a feature you do not get across an
--emit-obj edge. The refusal's own text names it as a correctness hazard:
"
gs:with no base does not read 0, it FAULTS -- and inside a glibc host it succeeds and returns glibc's TCB, which is worse."
That is the difference between an absent feature and a silent wrong one. An object with no ELF entry point has nothing to install the block, so the failure is not "thread-locals are process-wide" but "thread-locals resolve into another runtime's thread control block". Refusing is therefore not conservatism about interop; it is the only answer that is not silently wrong, and the reasoning this ticket offered would have supported a weaker guard.
The residual question that remains is unchanged and has no consumer: whether to grow an FS/psABI path so a pxx object carrying thread-locals can be linked into a gcc program. Nobody has produced such a consumer, which was this ticket's own deciding test. It reopens if one appears; it is not open now.
(b) IS NOT MINE AND IS NOT NEW — it already has a ticket and a worse measurement
The foreign-created-thread hazard bundled in above is
[[bug-a-a-foreign-thread-shares-the-main-thread-s-heap-magazine]], measured
2026-09-01, before the reading here. ir_codegen.inc:265 carries it: gdb
reports gs_base = 0x411f98 for ALL FIVE threads of
test/test_multithreading.pas — BSS_TLS_MAIN, the main thread's block —
because a libc-pthread_create thread never runs the clone stub that carves
and installs one. It cost 18 SIGSEGV in 100 runs through a shared heap
magazine, and the pinned v399 compiler crashes at the same rate, so it is not a
regression.
My probe reproduced the same fact by a different route (identical GS across
threads, via arch_prctl(ARCH_GET_GS) rather than gdb), which is corroboration
rather than a finding — two instruments that fail differently, agreeing.
Bundling it here was right at the time and is wrong now: it has an owner, and a
second copy of a measured hazard is how one of them goes stale. Follow that
ticket, not this section.
Resolving. The fork was ruled by code, and the residual has an owner.
Log
- 2026-09-09 — decided; this names the commit that carried the decision, which is not always the one that carried the change — commit 4a8aae977.