Every thread needs a private scratchpad of bookkeeping the runtime can reach instantly and without knowing the thread’s identity — its kernel thread ID, its list of held robust locks, its thread-local-storage pointers, its cancellation state, its stack canary. That scratchpad is the Thread Control Block (TCB), which glibc implements as struct pthread (defined in nptl/descr.h). It is anchored by the thread pointer: the %fs segment base on x86-64, which the code reaches with a single segment-relative memory access. The first bytes of the block are the ABI-defined tcbhead_t — holding the self-pointer, the DTV pointer, and, at fixed offsets, the security cookies stack_guard and pointer_guard. The rest is glibc’s private per-thread state. This note dissects that structure and how the thread pointer is set; the storage it anchors is Thread-Local Storage and the addressing that reaches it is TLS Models and Access.
Scope boundary, authorities, and what was measured
Where the line between this note and its siblings falls.Thread-Local Storage owns the mechanism and the ABI — .tdata/.tbss, PT_TLS, the DTV’s semantics, static versus dynamic allocation, the static-TLS surplus. TLS Models and Access owns the addressing — the four access models, their instruction sequences and relocations. This note owns the per-thread structure those two hang off: what is actually in struct pthread, at which offsets, who else reads those offsets, and how the %fs base that points at it gets installed. If you are asking “where does a __thread variable live?”, read the first. If you are asking “what instructions reach it?”, read the second. If you are asking “what is the thing %fs points at, and what else is in there?”, you are in the right place.
Which authority governs which claim. The existence and position of the header fields the compiler and loader depend on (tcb, dtv, self, stack_guard, pointer_guard) is an ABI matter — fixed by Drepper’s ELF Handling For Thread-Local StorageVersion 0.21, 22 August 2013 (the revision read here, fetched from akkadia.org) plus the System V ABI AMD64 Processor Supplement, Version 1.0, 12 March 2025. Everything past the header — every field described under “glibc’s Private State” below — is glibc’s private layout, pinned here to glibc 2.43 and explicitly not stable across releases; the source says so itself. The thread pointer’s installation is the kernel’s narrow contribution, pinned to Linux v6.12 (arch/x86/kernel/process_64.c, arch/x86/kernel/process.c).
Measured on this machine, 2026-09-11: Fedora 44, kernel 7.1.13, glibc 2.43-8.fc44, GCC 16.2.1, binutils 2.46.1. glibc source is read at the upstream tag glibc-2.43 from sourceware.org — note that the bminor/glibc GitHub mirror had not picked up that tag at the time of writing, which is why the previous revision of this note, citing that mirror’s master branch, was reading an unpinned moving target.
Mental Model
Picture the thread pointer as a this pointer for the running thread. Just as an object method reaches its fields through this, every thread reaches its own descriptor through %fs — and because %fs is set per thread by the kernel, the same instruction in shared code lands on a different descriptor in each thread. On x86-64 (a Variant II layout, see TLS Models and Access) the TCB sits at the top of the per-thread block with the TLS blocks below it, and the thread pointer points at the TCB itself. The very first word, %fs:0, is a self-pointer back to the descriptor, so code that needs the descriptor’s address (not just to index off it) can load it in one instruction.
flowchart TB
subgraph BLOCK["Per-thread memory block (x86-64, Variant II)"]
direction TB
TLSN["…other modules' TLS blocks…"]
TLS1["executable's TLS block<br/>(negative offsets from TP)"]
subgraph PTHREAD["struct pthread (the TCB)"]
HEAD["tcbhead_t (ABI header):<br/>[0x00] tcb [0x08] dtv → DTV<br/>[0x10] self [0x18] multiple_threads/gscope<br/>[0x20] sysinfo [0x28] stack_guard (canary)<br/>[0x30] pointer_guard (PTR_MANGLE)"]
PRIV["glibc private fields:<br/>list, tid, robust_head,<br/>cancelhandling, stackblock,<br/>specific[] (pthread_key TSD),<br/>start_routine, result, joinstate…"]
end
end
FS["%fs base (thread pointer)"] --> HEAD
FS -. "%fs:0 = self-pointer" .-> HEAD
HEAD -->|dtv| DTV["Dynamic Thread Vector"]
TLS1 -.->|"%fs:x@tpoff (negative)"| PTHREAD
The x86-64 thread control block. What it shows: the %fs base points at struct pthread; its ABI header tcbhead_t exposes the DTV pointer and the security cookies at fixed offsets (stack_guard at %fs:0x28, pointer_guard at %fs:0x30), with static TLS blocks living at negative offsets below and glibc’s private per-thread fields following. The insight to take: the fixed offsets are a contract — the compiler emits %fs:0x28 to read the stack canary and %fs:(dtv) to reach TLS without ever computing the descriptor’s address, which is why TLS and stack-protector accesses are single instructions.
The ABI Header: tcbhead_t
The head of the block is fixed by the ABI so that compiler-generated code and the dynamic linker can find well-known fields at well-known offsets, independent of glibc’s private layout. On x86-64 it is (sysdeps/x86_64/nptl/tls.h, verbatim):
typedef struct{ void *tcb; /* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */ dtv_t *dtv; void *self; /* Pointer to the thread descriptor. */ int multiple_threads; int gscope_flag; uintptr_t sysinfo; uintptr_t stack_guard; uintptr_t pointer_guard; unsigned long int unused_vgetcpu_cache[2]; unsigned int feature_1; int __glibc_unused1; void *__private_tm[4]; void *__private_ss; unsigned long long int ssp_base; __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32))); void *__padding[8];} tcbhead_t;
Walking the load-bearing fields:
tcb (offset 0x00) and self (0x10) both point back at the descriptor. self is “Pointer to the thread descriptor,” so %fs:0 (which is tcb) is the self-pointer that makes mov %fs:0, %rax load the TP into a register. The comment “For now the thread descriptor is at the same address” reflects that on x86-64 the TCB and struct pthread coincide.
dtv (0x08) is the pointer to this thread’s Dynamic Thread Vector — the array __tls_get_addr and the dynamic TLS models index to find module blocks.
multiple_threads (0x18) is a fast single-threaded flag: glibc checks it to skip locking and atomic overhead while a process is still single-threaded (see the mutex fast path in pthread Synchronization and Futexes).
sysinfo (0x20) historically cached the vDSO/__kernel_vsyscall entry point for fast system calls.
stack_guard (0x28) and pointer_guard (0x30) are the two security cookies, dissected below.
The tcb/self split and the placement of stack_guard at exactly %fs:0x28 are ABI promises the compiler hard-codes: GCC’s stack-protector reads %fs:0x28 directly, so this offset cannot change without breaking every stack-protected binary.
The complete offset map
Quoting the struct is not the same as knowing the offsets, and the offsets are what matters — they are literal constants in compiled code. Compiling the definition above verbatim and printing offsetof for every member gives the full map. glibc ships two of these offsets as its own _Static_asserts inside sysdeps/x86_64/nptl/tls.h, which makes them a built-in check on the reproduction:
/* morestack.S in libgcc uses offset 0x70 to access __private_ss, */_Static_assert (offsetof (tcbhead_t, __private_ss) == 0x70, "offset of __private_ss != 0x70");_Static_assert (offsetof (tcbhead_t, __glibc_unused2) == 0x80, "offset of __glibc_unused2 != 0x80");
Both of glibc’s own assertions hold on the reproduction, so the rest of the map can be trusted. In full:
Offset
Field
Size
Who reads it, and why it cannot move
0x00
tcb
8
The self-pointer. mov %fs:0, %rax loads the thread pointer into a register in one instruction — the only way to get the TP’s value, since a segment base is not otherwise readable in user mode without a syscall. Drepper fixes this: “the content of the word addressed via %fs:0 is the address of the very same location” (Drepper 2013 §3.4.6).
0x08
dtv
8
The Dynamic Thread Vector pointer (see Thread-Local Storage). Read by every general-dynamic and local-dynamic TLS access, via __tls_get_addr’s THREAD_DTV().
0x10
self
8
Second pointer to the descriptor. Distinct from tcb only on architectures where the TCB and struct pthread are not the same object; on x86-64 glibc sets both to the same value with the comment “For now the thread descriptor is at the same address.” THREAD_SELF is defined as a read of this field.
0x18
multiple_threads
4
Set once the process has spawned a thread (or self-cancelled). glibc tests it to skip lock prefixes and cancellation checks in single-threaded processes. Exported to assembly as MULTIPLE_THREADS_OFFSET in tcb-offsets.sym.
0x1c
gscope_flag
4
The global-scope flag — glibc’s asymmetric lock protecting lazy symbol resolution against a concurrent dlclose. Dissected below.
0x20
sysinfo
8
Historically the cached vDSO __kernel_vsyscall entry point for i386-style fast syscalls; vestigial on x86-64, guarded by NEED_DL_SYSINFO.
0x28
stack_guard
8
The stack canary. GCC’s -fstack-protector emits mov %fs:0x28, … inline in every protected function prologue. This is the single most load-bearing constant in the whole structure.
0x30
pointer_guard
8
The PTR_MANGLE/PTR_DEMANGLE cookie. Exported to assembly as POINTER_GUARD in tcb-offsets.sym because setjmp/longjmp are written in assembly and mangle in place.
0x38
unused_vgetcpu_cache[2]
16
Dead weight, kept only because removing it would move everything after it.
0x48
feature_1
4
Intel CET state. The source comments the bits: “Bit 0: X86_FEATURE_1_IBT. Bit 1: X86_FEATURE_1_SHSTK” — indirect-branch tracking and shadow stack. Exported as FEATURE_1_OFFSET.
0x4c
__glibc_unused1
4
padding
0x50
__private_tm[4]
32
Reserved for the Transactional Memory ABI.
0x70
__private_ss
8
GCC’s split-stack support. libgcc’s morestack.S hard-codes 0x70, which is why glibc asserts it.
0x78
ssp_base
8
The base of the current shadow stack under CET. Exported as SSP_BASE_OFFSET.
0x80
__glibc_unused2[8][4]
512
Dead, and explicitly frozen: “Must be kept even if it is no longer used by glibc since programs, like AddressSanitizer, depend on the size of tcbhead_t.” A 512-byte hole preserved for a third-party tool.
The first 128 bytes of tcbhead_t on x86-64 at glibc 2.43. Drawn with mermaid packet-beta, whose cells normally count bits; here each cell is one byte, so the ranges read directly as the hexadecimal offsets in the table above. What it shows: every byte offset the compiler, the dynamic linker, libgcc and third-party tooling hard-code, in one picture; the two security cookies sit adjacent at 0x28 and 0x30, immediately after the three pointers and the two flag words. The insight to take: the first six fields are the hot ones — self-pointer, DTV, self, flags, and the canary — packed into the first 48 bytes so that a single cache line covers everything a function prologue or a TLS access needs to touch. The remaining 576 bytes (__glibc_unused2 and __padding, not drawn) are frozen reservations, not live state.
Why 704 bytes of header for ~48 bytes of live fields
Over 80% of tcbhead_t is reservations that glibc itself no longer uses: 512 bytes of __glibc_unused2 kept alive solely because AddressSanitizer depends on sizeof (tcbhead_t), 64 bytes of __padding, 32 bytes for a Transactional Memory ABI that never shipped broadly on x86-64, and 16 bytes of dead vgetcpu cache. This is what an ABI looks like after two decades: you cannot shrink a structure whose size has become a dependency, only stop using the middle of it.
Reading the header at runtime
None of this needs a debugger. A short program can read its own header through %fs and check the claims against each other:
Five claims confirmed at once. pthread_self(), %fs:0 and %fs:0x10 are all the same value — the self-pointer identity, and the proof that a pthread_t is the descriptor’s address. The kernel agrees: arch_prctl(ARCH_GET_FS) returns the identical number, so the thread pointer is the hardware FS base, not a library abstraction. The two threads have different thread pointers but identical negative TLS offsets (-8 and -4 in both), which is the variant II layout doing its job. The child’s dtv is a low heap address (0x3f043030) while the main thread’s is a high mapped one — the main thread’s DTV comes from the loader’s early allocation, the child’s from ordinary malloc. And — the one that overturns a common misconception — both threads carry the exact same stack_guard and pointer_guard.
The Security Cookies
Two words in the header are secrets the loader randomizes per process to harden against memory-corruption exploits.
stack_guard (%fs:0x28) is the stack canary used by -fstack-protector. GCC’s prologue copies this value between a function’s locals and its saved return address; the epilogue re-reads %fs:0x28 and compares. A linear buffer overflow that smashes the return address must first overwrite the canary, and since the attacker cannot know the randomized value, the mismatch is caught and __stack_chk_fail aborts the process (Wikipedia: Buffer overflow protection). Here is GCC 16.2.1 doing it, on a function whose only sin is a fixed-size buffer:
$ gcc -O2 -fstack-protector-strong -c canary.c && objdump -d --no-show-raw-insn canary.o0000000000000000 <copy>: 0: sub $0x58,%rsp 4: mov %fs:0x28,%rsi <-- load the canary straight from the TCB d: mov %rsi,0x48(%rsp) <-- store it above the locals, below the return address 12: mov %rdi,%rsi 15: mov %rsp,%rdi 18: call 1d <copy+0x1d> <-- strcpy 1d: mov 0x48(%rsp),%rax <-- reload the stored copy 22: sub %fs:0x28,%rax <-- compare against the TCB's value 2b: jne 32 <copy+0x32> 2d: add $0x58,%rsp 31: ret 32: call 37 <copy+0x37> <-- __stack_chk_fail
Two instructions — mov %fs:0x28,%rsi and sub %fs:0x28,%rax — are the entire reason the offset 0x28 is frozen forever. There is no indirection, no function call, and no way to relocate the field: the constant is baked into every stack-protected function in every binary on the system. Storing the canary in the TCB rather than in a global is what makes it a single instruction to read; details of the mechanism, and the contrast with the hardware guard page, are in Thread Stacks and Stack Guards.
The canary is per-process, not per-thread — a very common misreading
The storage is per-thread (every thread has its own %fs:0x28), but the value is identical in every thread of a process. nptl/pthread_create.c copies it from the creating thread into the new descriptor before the thread starts:
/* Copy the stack guard canary. */#ifdef THREAD_COPY_STACK_GUARD THREAD_COPY_STACK_GUARD (pd);#endif/* Copy the pointer guard value. */#ifdef THREAD_COPY_POINTER_GUARD THREAD_COPY_POINTER_GUARD (pd);#endif
and the measured output above shows main and child carrying the identical 0x57ef3a8673b2eb00. It has to work this way: a longjmp or a __stack_chk_fail comparison must be meaningful across a stack frame that may have been set up by another thread, and a per-thread canary would make cross-thread stack inspection incoherent. The security consequence is real — an information leak that discloses the canary in any thread discloses it for all threads — and it is why this design is only a mitigation, not a boundary.
The randomization and its exact shape are worth reading, because the value has a tell. elf/rtld.c derives both cookies from _dl_random, which is the kernel’s 16 bytes from the auxiliary vector entry AT_RANDOM:
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random); THREAD_SET_STACK_GUARD (stack_chk_guard); ... uintptr_t pointer_chk_guard = _dl_setup_pointer_guard (_dl_random, stack_chk_guard); ... /* We do not need the _dl_random value anymore. The less information we leave behind, the better. */ _dl_random = NULL;
The two derivations differ in one crucial way (sysdeps/unix/sysv/linux/dl-osinfo.h):
The canary takes the first 8 bytes of AT_RANDOM and forces the low byte to zero; the pointer guard takes the second 8 bytes untouched. The zeroed byte is the classic terminator canary: on little-endian x86-64 the low byte is the first byte in memory, so a canary that begins with \0 cannot be reproduced by an overflow driven by strcpy, sprintf, gets or any other NUL-terminated string primitive — the attacker’s write stops at the very byte they would need to forge. It costs 8 bits of entropy (56 random bits remain) and defeats an entire class of attack. It is also a free self-check on the measurement: every canary this machine produced ends in 00.
$ for i in 1 2 3; do ./tcb | grep -m1 stack_guard; donemain stack_guard(%fs:0x28)=0x86a811ad4d30df00 pointer_guard(%fs:0x30)=0xd35c14e71c5495d6main stack_guard(%fs:0x28)=0x958c301ee5662e00 pointer_guard(%fs:0x30)=0x2e62f836730c2380main stack_guard(%fs:0x28)=0x6e5d3ffe13b65900 pointer_guard(%fs:0x30)=0xb466c8a809e19e9b
Three runs, three different canaries, all ending 00; three different pointer guards with no such pattern. That is the source code’s arithmetic, visible from the outside.
pointer_guard (%fs:0x30) backs glibc’s PTR_MANGLE/PTR_DEMANGLE macros. Sensitive function pointers stored in writable memory — atexit handlers, longjmp targets, some GOT-adjacent state — are scrambled with this cookie before being stored and unscrambled before use. On x86-64 the operation is exactly two instructions (sysdeps/unix/sysv/linux/x86_64/pointer_guard.h):
LP_SIZE is 8 on LP64, so the rotation is rol $17. The XOR alone would be reversible by an attacker who can observe one mangled/plaintext pair; the rotation by a non-multiple of 8 additionally smears the bytes so that a partial overwrite — the common case, where only the low bytes of a pointer are controlled — cannot be reasoned about byte-wise. The POINTER_GUARD name in that macro is a constant emitted from sysdeps/x86_64/nptl/tcb-offsets.sym, which exists precisely so hand-written assembly (setjmp, longjmp, _dl_start_user) can name the offset symbolically. One wrinkle: inside ld.so itself, where %fs is not yet set up, the same header falls back to xor __pointer_chk_guard_local(%rip), reg against a global, which security_init in elf/rtld.c sets unconditionally alongside the TCB copy.
stack_guard
pointer_guard
Offset
%fs:0x28
%fs:0x30
Source bytes
AT_RANDOM[0..7]
AT_RANDOM[8..15]
Post-processing
low byte forced to 0x00 (terminator canary)
none
Effective entropy
56 bits
64 bits
Scope
per process (copied to each new thread)
per process (copied to each new thread)
Emitted by
GCC -fstack-protector*, inline in prologue/epilogue
glibc’s PTR_MANGLE/PTR_DEMANGLE, including in assembly
Protects against
linear stack buffer overflow overwriting a return address
overwriting a stored function pointer with an attacker-chosen address
__stack_chk_guard, set by security_initonly in the #else branch — i.e. on architectures with no THREAD_SET_STACK_GUARD, which excludes x86-64
__pointer_chk_guard_local, set always, and used by ld.so’s own mangling before %fs is live
The CET fields: feature_1 and ssp_base
Two of the newer header fields belong to Intel Control-flow Enforcement Technology (CET). feature_1 at 0x48 carries two bits the source names explicitly — “Bit 0: X86_FEATURE_1_IBT. Bit 1: X86_FEATURE_1_SHSTK” — for indirect branch tracking (every indirect call must land on an endbr64 instruction) and shadow stack (the CPU keeps a second, write-protected copy of return addresses and faults if it disagrees with the ordinary stack). ssp_base at 0x78 records the base of the current thread’s shadow stack, which glibc needs when a longjmp or a cancellation unwind has to pop the shadow stack in step with the real one.
These are per-thread state by necessity — each thread has its own shadow stack — and so they live in the TCB rather than in a global. On this machine they are inert:
$ ./cetfeature_1(%fs:0x48)=0x0 (bit0 IBT=0, bit1 SHSTK=0)__private_ss(%fs:0x70)=(nil) ssp_base(%fs:0x78)=0x0$ readelf -n ./cet | grep -A2 Properties Properties: x86 ISA needed: x86-64-baseline x86 feature used: x86
A CET-enabled binary carries IBT and SHSTK in its GNU_PROPERTY_X86_FEATURE_1_AND note and the loader sets the corresponding bits in feature_1; this binary carries neither, and the kernel on this host has shadow stacks disabled, so both fields read zero. The ARCH_SHSTK_ENABLE / ARCH_SHSTK_STATUS sub-functions of arch_prctl exist at Linux v6.12 (arch/x86/include/uapi/asm/prctl.h) — the plumbing is in place, the feature is simply off here.
struct pthread: glibc’s Private State
Following the ABI header, struct pthread in nptl/descr.h carries glibc’s per-thread bookkeeping. The transition between the two halves is a union, and the union is worth reading before the fields:
struct pthread{ union {#if !TLS_DTV_AT_TP /* This overlaps the TCB as used for TLS without threads (see tls.h). */ tcbhead_t header;#else struct { int multiple_threads; int gscope_flag; } header;#endif /* This extra padding has no special purpose, and this structure layout is private and subject to change without affecting the official ABI. */ void *__padding[24]; }; ...
Two things are being said here. On a variant II architecture like x86-64 the whole tcbhead_tis the first member, which is why %fs:0x28 reaches the canary and %fs:0x08 reaches the DTV — the TCB and struct pthread are literally the same object at the same address. And the comment is an explicit disclaimer: everything below is private and subject to change without affecting the official ABI. That disclaimer is not decorative, as the version-skew example at the end of this section shows.
Field
Type
What it is for
Who else touches it
header
tcbhead_t (union)
the ABI header dissected above
GCC, ld.so, libgcc, ASan
list
list_t
links the descriptor onto GL(dl_stack_used) or GL(dl_stack_user)
__thread_gscope_wait, setxid, teardown
tid
pid_t
the kernel thread ID, written by the kernel at clone time via CLONE_PARENT_SETTID
pthread_kill → tgkill, gettid caching
robust_prev / robust_head
struct robust_list_head
head of the robust-futex list, registered with the kernel by set_robust_list
per-thread scratch for libc functions with static-buffer legacy APIs (strsignal, strerror)
libc internals
The kernel writes into this structure — and where it writes changed in glibc 2.43
Two of those fields are written by the kernel, not by glibc, which is what makes their offsets quietly load-bearing across the syscall boundary. pthread_create passes their addresses in struct clone_args:
CLONE_PARENT_SETTID makes the kernel write the new thread’s TID into pd->tid. CLONE_CHILD_CLEARTID makes the kernel zero the word at child_tid and FUTEX_WAKE on it when the thread exits (clone(2)) — and that word is pd->joinstate, notpd->tid. Because THREAD_STATE_EXITED is defined as 0, “the kernel zeroes it” and “the kernel sets the state to EXITED” are the same event. pthread_join then simply futex-waits on it:
while ((state = atomic_load_acquire (&pd->joinstate)) != THREAD_STATE_EXITED) { ... int ret = __futex_abstimed_wait64 (&pd->joinstate, state, clockid, abstime, LLL_SHARED);
Version skew: this was pd->tid through glibc 2.42
The older and very widely repeated description — “pthread_join waits on the tid field, which the kernel clears via CLONE_CHILD_CLEARTID” — was correct up to and including glibc 2.42 and became wrong in glibc 2.43. Verified by fetching nptl/pthread_create.c at each tag: glibc-2.34 through glibc-2.42 all carry .child_tid = (uintptr_t) &pd->tid, while glibc-2.43 carries .child_tid = (uintptr_t) &pd->joinstate. The joinstate field does not exist at all in glibc-2.42’s nptl/descr.h; its predecessor was struct pthread *joinid, with detachment encoded as the self-referential IS_DETACHED(pd) ((pd)->joinid == (pd)).
The rationale is on record. The change is patch 3/6 of Adhemerval Zanella’s series “nptl: Do not use pthread set_tid_address as state synchronization (BZ #19951)” (libc-alpha, 11 December 2025), which fixes a use-after-free: “The use-after-free described in BZ#19951 is due to the use of two different PD fields, joinid and cancelhandling, to describe the thread state and to synchronise the calls of pthread_join, pthread_detach, pthread_exit, and normal thread exit. Any state change may require checking both fields atomically to handle partial state.” The fix collapses both into one word with four states, and — point 4 of the commit message — “The clear_tid_field on clone call is changed to set the new state field on thread exit (EXITED).” The payoff is stated in point 5: “The pthread_join implementation is now simpler: the futex wait is done directly on thread state, and there is no need to reset it in case of timeout.”
This is the private-layout disclaimer in the union comment doing exactly what it warns about, in a single release, to the most frequently described field in the structure. Any tool that hard-codes struct pthread offsets — and libthread_db is one — must be matched to the glibc it is inspecting.
A crucial identity: the opaque pthread_t handle is simply the address of this struct pthread. Drepper’s NPTL paper is explicit — “the thread handle is simply the pointer to the thread descriptor,” which is why “successively created threads will get the same handle” when glibc reuses a cached stack (Drepper & Molnar 2005 §5.6). That is the concrete reason POSIX forbids comparing pthread_t with == (use pthread_equal) and why using a handle after join/detach is undefined.
The gscope Flag and Safe dlclose
One header field deserves more than a line because it solves a genuinely hard problem: gscope_flag (offset 0x1c, sharing space with multiple_threads). “gscope” is glibc’s global scope lock, and it exists to make [[dlopen and Runtime Loading|dlclose]] safe in the presence of lazy PLT resolution.
The problem: when the dynamic linker resolves a symbol lazily, it walks the global lookup scope — the ordered list of loaded objects (see Symbol Resolution and Lookup Scope). If another thread calls dlclose and unmaps an object mid-walk, the resolving thread reads freed memory and crashes. glibc cannot take a heavyweight lock around every lazy resolution — that would serialize all symbol lookups — so it uses a per-thread flag instead, with a deliberately asymmetric cost.
The flag has three values (sysdeps/x86_64/nptl/tls.h):
Entering a scope walk is genuinely free — THREAD_GSCOPE_SET_FLAG() is a plain store of 1 through %fs, with no atomic and no barrier. Leaving is almost free, and the “almost” is the interesting part:
The exit is an xchgl against %fs-relative memory — and xchg with a memory operand on x86 is implicitly lock-prefixed, so this is a full atomic read-modify-write, not a plain store. That is the minimum needed: the exiting thread must learn atomically whether a dlclose upgraded its flag from USED to WAIT in the interval, because if it did, the exiting thread owes it a FUTEX_WAKE. One locked exchange per lazy symbol resolution is the entire fast-path cost of dlclose safety.
The slow side is __thread_gscope_wait (sysdeps/nptl/dl-thread_gscope_wait.c), and it is not a spin:
void__thread_gscope_wait (void){ lll_lock (GL (dl_stack_cache_lock), LLL_PRIVATE); struct pthread *self = THREAD_SELF; list_t *runp; list_for_each (runp, &GL (dl_stack_used)) /* and again for dl_stack_user */ { struct pthread *t = list_entry (runp, struct pthread, list); if (t == self || t->header.gscope_flag == THREAD_GSCOPE_FLAG_UNUSED) continue; int *const gscope_flagp = &t->header.gscope_flag; /* We have to wait until this thread is done with the global scope. First tell the thread that we are waiting and possibly have to be woken. */ if (atomic_compare_and_exchange_bool_acq (gscope_flagp, THREAD_GSCOPE_FLAG_WAIT, THREAD_GSCOPE_FLAG_USED)) continue; do futex_wait_simple ((unsigned int *) gscope_flagp, THREAD_GSCOPE_FLAG_WAIT, FUTEX_PRIVATE); while (*gscope_flagp == THREAD_GSCOPE_FLAG_WAIT); } lll_unlock (GL (dl_stack_cache_lock), LLL_PRIVATE);}
It walks both thread lists — dl_stack_used (glibc-allocated stacks) and dl_stack_user (stacks the application supplied via pthread_attr_setstack) — which is the concrete purpose of the list member of struct pthread described above. For each other thread it skips the common UNUSED case with a plain read, and for a thread actually in a walk it CASes USED → WAIT and then futex-waits, blocking rather than burning CPU. The continue on CAS failure is the race being handled: the thread finished between the read and the CAS, so there is nothing to wait for.
sequenceDiagram
autonumber
participant W as worker thread<br/>(lazy PLT resolution)
participant F as its gscope_flag<br/>at %fs:0x1c
participant D as dlclose thread
W->>F: THREAD_GSCOPE_SET_FLAG — plain store of 1 (USED)<br/>no atomic, no barrier
Note over W: walks the global scope,<br/>reading link maps that dlclose wants to free
D->>D: remove the objects from every scope,<br/>then call __thread_gscope_wait
D->>D: lll_lock(dl_stack_cache_lock), walk<br/>dl_stack_used and dl_stack_user
D->>F: read flag — it is USED, so CAS USED to WAIT (2)
D->>D: futex_wait on the flag while it reads WAIT
Note over W: finishes the walk
W->>F: THREAD_GSCOPE_RESET_FLAG — xchgl to 0,<br/>implicitly locked, returns the OLD value
W->>W: old value was WAIT, so owe a wake
W->>D: lll_futex_wake(flag, 1)
D->>D: wakes, flag is UNUSED, moves to the next thread
D->>D: all threads clear — munmap the objects for real
The gscope handshake. What it shows: the worker pays one plain store on entry and one locked exchange on exit, with no knowledge of whether any dlclose is in flight; all the bookkeeping — walking the thread list, upgrading flags, blocking — is on the dlclose side. The insight to take: this is textbook asymmetric synchronization, and it is only possible because the flag lives at a fixed %fs offset inside a structure that a different thread can also reach through the global thread list. A per-thread variable that were only reachable through %fs could not be polled by dlclose at all.
Finding the Descriptor from Outside: Debuggers
The descriptor is not only reached by the thread itself. libthread_db — the helper library GDB and other debuggers load to understand a live or core-dumped multithreaded process — walks these structures from outside the process, where %fs is not available at all: the debugger is a different process, and the inferior’s segment base is not something it can simply read.
glibc solves this by exporting the recipe. sysdeps/x86_64/nptl/tls.h carries, right next to the ordinary THREAD_SELF definition, a second one labelled for the debugger:
/* Magic for libthread_db to know how to do THREAD_SELF. */# define DB_THREAD_SELF_INCLUDE <sys/reg.h> /* For the FS constant. */# define DB_THREAD_SELF CONST_THREAD_AREA (64, FS)
CONST_THREAD_AREA (64, FS) tells libthread_db: on a 64-bit target, the thread descriptor’s address is the FS base, which you can fetch with ptrace(PTRACE_PEEKUSER) at the FS offset in the target’s user_regs_struct. The same macro on AArch64 says something different and equally revealing:
— because AArch64 is variant I, the descriptor sits sizeof (struct pthread) bytes below the thread pointer, and the debugger must subtract. The entire architecture-specific “how do I find a thread descriptor from outside” question is thus reduced to one constant per port.
From that anchor libthread_db walks the list members through GL(dl_stack_used) and GL(dl_stack_user) — the same two lists __thread_gscope_wait walks — and watches td_eventbuf_t eventbuf and nextevent, which glibc updates on thread creation and death so a debugger can breakpoint “new thread” and “thread exit” rather than poll.
This is the concrete reason the “private” layout is not entirely free to change: libthread_db is a separately compiled binary that hard-codes offsets into struct pthread, so glibc ships and versions the two together. Mixing them is the classic Cannot find new threads: generic error — most often hit when debugging a core file or container image whose glibc differs from the host’s, which makes matching the debug infrastructure to the target’s glibc a prerequisite, not a nicety. It is also why a corrupted TCB makes GDB’s info threads misbehave: the debugger is chasing pointers through the very structure the bug damaged.
How the Thread Pointer Gets Set
For the TCB to work, %fs must be pointed at the descriptor before the thread runs any TLS-using code. There are two mechanisms, one for the main thread and one for new threads.
The main thread is set up during process startup. glibc allocates the initial descriptor and calls its TLS_INIT_TP macro, which “sets _head->tcb and _head->self to the thread descriptor” and then “invokes arch_prctl(ARCH_SET_FS, thrdescr)… setting the x86-64 FS segment register to point to the TCB base” (sysdeps/x86_64/nptl/tls.h). arch_prctl(ARCH_SET_FS, addr) is the x86-64 system call that writes the %fs base MSR; before it existed, this required a costly GDT/LDT descriptor manipulation, which was one of the pain points the NPTL redesign eliminated.
That description is second-hand; the macro itself (glibc 2.43, sysdeps/x86_64/nptl/tls.h) shows there is no libc wrapper involved at all — it is a raw syscall instruction, because errno does not exist yet at this point in startup:
# define TLS_INIT_TP(thrdescr) \ ({ void *_thrdescr = (thrdescr); \ tcbhead_t *_head = _thrdescr; \ int _result; \ \ _head->tcb = _thrdescr; \ /* For now the thread descriptor is at the same address. */ \ _head->self = _thrdescr; \ \ /* It is a simple syscall to set the %fs value for the thread. */ \ asm volatile ("syscall" \ : "=a" (_result) \ : "0" ((unsigned long int) __NR_arch_prctl), \ "D" ((unsigned long int) ARCH_SET_FS), \ "S" (_thrdescr) \ : "memory", "cc", "r11", "cx"); \ \ _result == 0; \ })
Note the ordering: tcb and self are written before the syscall, so that the instant %fs becomes live, %fs:0 and %fs:0x10 already read back the descriptor’s own address. The comment “It is a simple syscall” is doing quiet historical work — this used to require manipulating an LDT descriptor via modify_ldt, which was one of the concrete pain points the NPTL redesign eliminated.
For a new thread, glibc avoids a second arch_prctl by folding the TP setup into thread creation: pthread_create allocates the descriptor, then issues clone with CLONE_SETTLS, whose tls argument on x86-64 “is the new value to be set for the %fs base register” (clone(2)). The kernel installs the %fs base as part of creating the task, so the new thread wakes up already able to reach its TCB and TLS — no window in which TLS is unusable. This is exactly the “signal-safe loading of the thread register” the NPTL kernel work added: “the new thread must be started by the kernel with the thread register already loaded… what… the CLONE_TLS flag” provides (Drepper & Molnar 2005 §6).
What the kernel actually does — Linux v6.12
The kernel’s whole contribution to TLS is installing one 64-bit register base, and the two entry points converge almost immediately. CLONE_SETTLS is handled in arch/x86/kernel/process.c, where copy_thread calls:
static int set_new_tls(struct task_struct *p, unsigned long tls){ struct user_desc __user *utls = (struct user_desc __user *)tls; if (in_ia32_syscall()) return do_set_thread_area(p, -1, utls, 0); else return do_set_thread_area_64(p, ARCH_SET_FS, tls);}
So on 64-bit, CLONE_SETTLS is literally the ARCH_SET_FS path — the same code arch_prctl reaches. That path, in arch/x86/kernel/process_64.c, is short:
case ARCH_SET_FS: { /* * Not strictly needed for %fs, but do it for symmetry * with %gs */ if (unlikely(arg2 >= TASK_SIZE_MAX)) return -EPERM; preempt_disable(); /* * Set the selector to 0 for the same reason * as %gs above. */ if (task == current) { loadseg(FS, 0); x86_fsbase_write_cpu(arg2); /* * On non-FSGSBASE systems, save_base_legacy() expects * that we also fill in thread.fsbase. */ task->thread.fsbase = arg2; } else { task->thread.fsindex = 0; x86_fsbase_write_task(task, arg2); } preempt_enable(); break; }
Line by line. The arg2 >= TASK_SIZE_MAX check returns -EPERM for a base in kernel address space — cheap, and the reason arch_prctl(ARCH_SET_FS, ...) cannot be used to point %fs at kernel memory. loadseg(FS, 0) zeroes the selector, decoupling the base from any GDT/LDT entry: on x86-64 the segment base is an independent MSR, and a non-zero selector would make the base reloadable from a descriptor table on the next __switch_to. The task == current split matters because CLONE_SETTLS runs in the parent’s context configuring a not-yet-running child — that takes the else branch, which only records the value in thread.fsbase for the scheduler to install later. arch_prctl from a thread setting its own base takes the if branch and writes the CPU register immediately.
The write itself is where the modern hardware shows up (arch/x86/include/asm/fsgsbase.h):
static inline void x86_fsbase_write_cpu(unsigned long fsbase){ if (boot_cpu_has(X86_FEATURE_FSGSBASE)) wrfsbase(fsbase); else wrmsrl(MSR_FS_BASE, fsbase);}
FSGSBASE is an Intel/AMD instruction-set extension (WRFSBASE, RDFSBASE, WRGSBASE, RDGSBASE) that lets the base registers be written without an MSR access. On a pre-FSGSBASE CPU, writing %fs base means wrmsr — a serializing, ~100-cycle instruction. With FSGSBASE it is a single unprivileged instruction, which matters far more on the context-switch path than at thread creation, since __switch_to must restore %fs base on every switch between threads of different processes:
static __always_inline void x86_fsgsbase_load(struct thread_struct *prev, struct thread_struct *next){ if (static_cpu_has(X86_FEATURE_FSGSBASE)) { /* Update the FS and GS selectors if they could have changed. */ if (unlikely(prev->fsindex || next->fsindex)) loadseg(FS, next->fsindex); ... /* Update the bases. */ wrfsbase(next->fsbase); __wrgsbase_inactive(next->gsbase); } else { load_seg_legacy(prev->fsindex, prev->fsbase, next->fsindex, next->fsbase, FS); ... }}
The unlikely(prev->fsindex || next->fsindex) is the payoff from ARCH_SET_FS having zeroed the selector: because every glibc thread has fsindex == 0, the selector reload is skipped and the switch costs one wrfsbase. This is the concrete, measurable cost of the thread pointer — one instruction per context switch — and it is the reason the whole TLS design puts the pointer in a register base rather than in a table the kernel would have to reload.
sequenceDiagram
autonumber
participant App as application
participant PC as glibc pthread_create
participant K as kernel (Linux v6.12)
participant Child as the new thread
Note over PC: allocate the per-thread block —<br/>_dl_allocate_tls_storage: malloc of<br/>static TLS + struct pthread, then allocate_dtv
PC->>PC: copy the parent's canary and pointer guard<br/>THREAD_COPY_STACK_GUARD / THREAD_COPY_POINTER_GUARD
PC->>PC: tls_setup_tcbhead(pd) — fill the ABI header
App->>PC: pthread_create(&t, attr, fn, arg)
PC->>K: clone3 with CLONE_SETTLS, CLONE_PARENT_SETTID,<br/>CLONE_CHILD_CLEARTID, tls = pd
K->>K: copy_thread → set_new_tls(p, tls)
K->>K: do_set_thread_area_64(p, ARCH_SET_FS, tls)<br/>task != current, so fsindex = 0 and thread.fsbase = tls
K->>PC: returns the new TID, written into pd->tid<br/>by CLONE_PARENT_SETTID
K->>Child: schedule it — __switch_to runs<br/>x86_fsgsbase_load, wrfsbase(next->fsbase)
Note over Child: %fs is live on the very first instruction.<br/>There is no window in which TLS is unusable.
Child->>Child: start_thread → fn(arg)
Child->>K: exit
K->>K: CLONE_CHILD_CLEARTID — zero pd->joinstate<br/>(THREAD_STATE_EXITED) and FUTEX_WAKE on it
K-->>App: pthread_join's futex wait on pd->joinstate returns
Thread creation from the thread pointer’s point of view, glibc 2.43 on Linux v6.12. What it shows: glibc builds the entire descriptor — TLS blocks, DTV, ABI header, copied security cookies — before the clone, then hands the kernel three separate jobs in one call: install %fs (CLONE_SETTLS), report the TID (CLONE_PARENT_SETTID), and publish thread death (CLONE_CHILD_CLEARTID). The insight to take: the kernel never allocates or interprets the TCB; it only stores one pointer value into a register base and later zeroes one word inside it. Everything else about the structure is glibc’s, which is exactly why its layout is free to change between releases while the %fs contract is not.
arch_prctl is not a user-facing API here
The arch_prctl(2) man page states the constraint plainly: “FS may be already used by the threading library. Programs that use ARCH_SET_FS directly are very likely to crash.” Reading the base with ARCH_GET_FS is safe and useful (it is what the measurement above does); writing it in a glibc program destroys the thread’s access to its own TCB, its TLS, and its stack canary in one instruction.
Failure Modes and Common Misunderstandings
Overwriting %fs:0x28 breaks stack protection silently. Inline assembly or a wild write that clobbers the TCB header can corrupt the canary or DTV pointer; the symptom is a spurious *** stack smashing detected *** abort or a TLS segfault far from the real bug.
Assuming pthread_t is a small integer. It is a pointer into this structure; printing it as an ID or hashing it across process boundaries is meaningless. Stale handles alias reused descriptors (see the == warning above).
tcbhead_t layout is arch-specific. The offsets quoted here (stack_guard at 0x28, pointer_guard at 0x30) are x86-64. i386, AArch64, and others have different tcbhead_t layouts; the concepts transfer but the numeric offsets do not.
DTV pointer corruption after dlopen. If the header’s dtv field is stale relative to the global generation (see Thread-Local Storage), a TLS access triggers a DTV resize; a bug in a dlopened module that corrupts the DTV pointer manifests as garbage TLS reads.
Believing the canary is per-thread. Covered at length above: the slot is per-thread, the value is per-process, copied by THREAD_COPY_STACK_GUARD at pthread_create. A leak in one thread compromises the mitigation for all of them.
Citing glibc’s master branch as a source for the layout.struct pthread is explicitly private and moves. The tid-versus-joinstate change above landed within one release and invalidated the most commonly repeated description of pthread_join. Always pin a tag, and say which. (This note’s previous revision cited raw.githubusercontent.com/bminor/glibc/master/..., which is unreproducible by construction.)
Assuming %fs:0 works before TLS_INIT_TP. In very early startup — before security_init, before the self-pointer is written — the %fs base is whatever the kernel left it as. Any code running there (an ifunc resolver, an unusually early constructor, a LD_AUDIT module) that touches TLS or a stack-protected function will fault or read garbage. This is why ld.so itself is built with restricted TLS models and its own mangling fallback against __pointer_chk_guard_local rather than %fs:POINTER_GUARD.
Expecting %fs to survive a longjmp across threads.setjmp/longjmp mangle the saved stack and instruction pointers with pointer_guard, which is process-wide and therefore will demangle correctly in another thread — so the mangling offers no protection against this mistake. The result is a thread running on another thread’s stack with its own %fs, which corrupts both. longjmp across threads is undefined and the TCB will not stop you.
Reading tcbhead_t offsets from the wrong architecture. The offsets quoted here are x86-64 only, and this is worth restating because they get copied into portable code. Compare sysdeps/aarch64/nptl/tls.h at the same glibc 2.43 tag — the entire header is two words:
/* The TP points to the start of the thread blocks. */# define TLS_DTV_AT_TP 1# define TLS_TCB_AT_TP 0typedef struct { dtv_t *dtv; void *private; } tcbhead_t;# define TLS_PRE_TCB_SIZE sizeof (struct pthread)# define THREAD_SELF ((struct pthread *)__builtin_thread_pointer () - 1)
There is no stack_guard field on AArch64 at all — no THREAD_SET_STACK_GUARD, so security_init takes its #else branch and the canary lives in the global __stack_chk_guard. And struct pthread sits below the thread pointer (THREAD_SELF subtracts one whole descriptor from __builtin_thread_pointer()), the exact inverse of x86-64. The concepts transfer; not one number does.
x86-64
AArch64
Layout variant
II (TLS_TCB_AT_TP 1)
I (TLS_DTV_AT_TP 1)
Thread pointer
%fs segment base (MSR, or wrfsbase)
TPIDR_EL0 system register
Read the TP
mov %fs:0, %rax via the self-pointer
__builtin_thread_pointer(), a direct mrs
tcbhead_t
704 bytes, 16 members
16 bytes: { dtv, private }
struct pthread is
at the TP — the header is its first member
before the TP, THREAD_SELF = (struct pthread *)tp - 1
Static TLS blocks
below the TP, negative @tpoff
above the TP, positive offsets
Stack canary lives in
the TCB, at %fs:0x28
the global __stack_chk_guard
TCB_ALIGNMENT
64 (cache-line, for segment-override performance)
generic 32
flowchart LR
TP(["thread pointer<br/>%fs base"])
subgraph TCB["struct pthread (x86-64)"]
H["tcbhead_t — the ABI half"]
P["glibc private half"]
end
TP --> H
GCC["GCC-generated code"] -->|"mov %fs:0x28 in every<br/>stack-protected prologue"| H
GCC -->|"%fs:0 / %fs:x@tpoff<br/>for TLS access"| H
LD["ld.so — dynamic linker"] -->|"writes stack_guard, pointer_guard<br/>in security_init at startup"| H
LD -->|"reads/updates dtv on<br/>dlopen (_dl_update_slotinfo)"| H
LD -->|"CAS gscope_flag USED→WAIT<br/>during dlclose"| H
LIBGCC["libgcc morestack.S"] -->|"hard-codes offset 0x70<br/>for __private_ss"| H
ASAN["AddressSanitizer"] -.->|"depends on sizeof(tcbhead_t)<br/>— hence 512 frozen bytes"| H
KERN["Linux kernel"] -->|"CLONE_SETTLS installs the base;<br/>CLONE_PARENT_SETTID writes tid"| P
KERN -->|"CLONE_CHILD_CLEARTID zeroes<br/>joinstate on exit + FUTEX_WAKE"| P
KERN -->|"walks robust_head on<br/>thread death"| P
TDB["libthread_db / GDB"] -.->|"walks dl_stack_used from<br/>OUTSIDE the process, by<br/>compiled-in offsets"| P
LIBC["glibc internals"] -->|"tid, cancelhandling,<br/>specific[], stackblock…"| P
Everyone who reads the Thread Control Block, and through which door. What it shows: the ABI half (tcbhead_t) is touched by four independent parties — compiler-generated code, the dynamic linker, libgcc, and a sanitizer that depends on its size — while the private half is touched by glibc, by the kernel (which writes three different fields), and by libthread_db reading the process from outside. The insight to take: “private” means “glibc may change the layout”, not “only glibc reads it”. Three of these consumers cannot be recompiled in lockstep with libc — the kernel writes through pointers glibc hands it, libgcc’s assembly is already in every binary, and libthread_db is a separate file — which is the real reason the header’s offsets are frozen and the reason a mismatched libthread_db breaks GDB’s thread support.
Alternatives and When to Choose Them
“The TCB” is one point in a design space, and the alternatives are instructive because each of them is in production somewhere.
A kernel-managed table instead of a register base (the historical x86-32 design). Before arch_prctl and the %fs-base MSR, i386 reached TLS through %gs pointing at a GDT/LDT descriptor that the kernel had to install with modify_ldt or set_thread_area and reload on every context switch. The register-base design replaced a descriptor-table entry with a single MSR (and later a single wrfsbase instruction), which is why TLS_INIT_TP can be a bare syscall and why __switch_to costs one instruction. The old design’s only advantage was working on hardware with no per-thread base register — an advantage that no longer exists on any relevant target.
musl’s smaller, simpler struct __pthread. musl uses the same %fs thread pointer and its own much smaller descriptor, but the load-bearing difference is that it reserves no static-TLS surplus at all. Where glibc quietly satisfies a dlopened initial-exec module out of a 1,664-byte reservation until that runs out, musl refuses on the spot (ldso/dynlink.c at musl v1.2.5):
if ((type == REL_TPOFF || type == REL_TPOFF_NEG) && def.dso->tls_id > static_tls_cnt) { error("Error relocating %s: %s: initial-exec TLS " "resolves to dynamic definition in %s", dso->name, name, def.dso->name); longjmp(*rtld_fail, 1); }
static_tls_cnt is the number of modules that were present at startup; any module with a higher tls_id is by definition dynamically loaded, and an initial-exec relocation in it is an error regardless of size. That is a legitimate and arguably better design choice — deterministic failure over best-effort success — and it is exactly why a plugin that loads fine on a glibc distribution can fail on Alpine with a different and more informative message. The corollary is that musl’s per-thread overhead is markedly lower, since there is no reservation to carry in every thread.
A per-CPU base instead of a per-thread one (the kernel’s own %gs). Inside the kernel, %gs points at per-CPU data, not per-task data, and the swapgs instruction switches between the user and kernel bases at the syscall boundary. The trade is exactly inverted: per-CPU state needs no update on a context switch but requires preemption discipline to use safely, while per-thread state is safe to use anywhere but must be reloaded on every switch. Userspace has no swapgs equivalent, which is one reason there is no per-CPU userspace analogue of the TCB — the nearest thing is Restartable Sequences.
Green threads / user-level scheduling, where the TCB must move. Any runtime that multiplexes many logical threads onto one OS thread — Go’s goroutines, a fiber library, a C++ coroutine scheduler — breaks the assumption that %fs identifies the current unit of execution. Go solves it by not using the C TCB for its own state at all (goroutine-local data lives in the g structure, reached through a register the Go compiler reserves) and only touching %fs when it must call into C. A C fiber library that switches stacks without switching %fs gets a genuinely nasty class of bug: errno, the malloc tcache, and every __thread variable silently belong to the OS thread, not the fiber.
Uncertain
Verify: the specific claim that Go keeps goroutine-local state in a g structure reached through a compiler-reserved register rather than through the C TCB, and what exactly Go does with %fs when crossing into C via cgo. Reason: this paragraph is from general knowledge; no Go runtime source was read during this task, unlike every other claim in this note. The glibc, kernel and musl comparisons here are verified against pinned source. To resolve: read runtime/go_tls.h, runtime/asm_amd64.s (get_tls/g macros) and runtime/cgocall.go at a pinned Go release, and cross-check against GMP Scheduler Model. uncertain
Design
Thread pointer
Switch cost
Fails how when a module wants more static TLS
Where it is used
glibc on x86-64
%fs base MSR / wrfsbase
one instruction in __switch_to
best-effort: succeeds until the 1,664-byte surplus is spent, then dlopen fails
every mainstream Linux distribution
i386, pre-set_thread_area
%gs selector into the LDT
descriptor reload
same, plus an LDT entry per thread
historical
musl
%fs base, same mechanism
same
deterministic: fails at once, no surplus
Alpine, static-linking-focused systems
kernel per-CPU (%gs + swapgs)
per-CPU, not per-task
zero on task switch, one swapgs per syscall
n/a
the Linux kernel itself
language runtime with its own descriptor
a reserved register (Go’s g)
runtime-managed
n/a — does not use the C TLS machinery
Go, some JITs
Production Notes
A corrupted TCB presents as a bug somewhere else. This is the single most useful thing to know about the structure operationally. Because the header packs the DTV pointer, the canary, and the gscope flag into the first 64 bytes, a wild write of a few words near %fs produces symptoms with no obvious common cause: *** stack smashing detected *** in a function with no buffer, a segfault inside __tls_get_addr, a hang in dlclose, or info threads in GDB printing nonsense. The diagnostic move is to dump the header early and compare it against its invariants — %fs:0, %fs:0x10 and pthread_self() must all be equal, and on a little-endian build the canary at %fs:0x28 must end in a zero byte:
If self does not equal the base, the header is damaged and every subsequent theory about the bug is unreliable.
The per-thread cost is real at scale. Everything described here is allocated per thread: sizeof(struct pthread) plus the whole static TLS block plus the surplus. Measured on this machine, that is 4,224 bytes before the thread’s own stack, for a program with 8 bytes of its own TLS — the number _dl_get_tls_static_info reports. At 10,000 threads that is roughly 42 MB of descriptor and reservation, entirely separate from stacks. glibc mitigates the allocation cost by caching stacks (and with them the descriptors) rather than the memory cost; see Thread Stacks and Stack Guards. If thread counts are in the thousands, this is one of several reasons to prefer a pool (see Thread Pools and Executor Design).
The surplus arithmetic is still moving. As of September 2026 there is active work on libc-alpha extending the tunables described in Thread-Local Storage — DJ Delorie’s glibc.rtld.optional_static_tls_alignment (August 2026) and a separate glibc.rtld.audit_static_tls, motivated because “when audit modules are used the loader can’t compute TLS space before the auditors run” (August 2026). The 1,664-byte figure itself is a deliberately preserved historical constant — glibc carries a regression test, elf/tst-tls-surplus.c, added in 2020 with the commit “elf: Change TLS static surplus default back to 1664” specifically to pin it. Treat any surplus number, including the ones measured in these notes, as dated.
The descriptor is cache-line aligned, and for a documented microarchitectural reason._dl_get_tls_static_info reports an alignment of 64 on this machine, which is not the generic value: nptl/descr.h defines TCB_ALIGNMENT as 32 only #ifndef, and sysdeps/x86/nptl/pthreaddef.h overrides it with an explanation worth quoting, because it is the rare case of an ABI constant justified by a hardware erratum-adjacent behaviour:
/* ... access using a segment override if that segment's base is not aligned to the size of a cache line. (See Intel 64 and IA-32 Architectures Optimization Reference Manual, section 13.3.3.3, "Segment Base".) On such machines, a cache line is 64 bytes. */#define TCB_ALIGNMENT 64
Every %fs:-relative access in the process — every TLS read, every stack-canary check — is a segment-override access, and on the affected parts those are penalised unless the segment base sits on a cache-line boundary. So struct pthread carries __attribute ((aligned (TCB_ALIGNMENT))), the loader rounds the whole static TLS block up to 64, and that rounding is the reason the measured dlopen surplus ceiling in Thread-Local Storage can exceed the arithmetic by a few tens of bytes.
See Also
Thread-Local Storage — the DTV the dtv header field points at, static versus dynamic blocks, and the static-TLS surplus. That note owns the storage mechanism and the ELF ABI; this one owns the structure it hangs off.
TLS Models and Access — the four access models and the instruction sequences that reach this block via %fs / TPIDR_EL0. That note owns the code sequences.
POSIX Threads and NPTL — pthread_t is the address of this descriptor; at glibc 2.43 it is joinstate, not tid, that powers pthread_join
Threads as Tasks and the CLONE Flags — CLONE_SETTLS, CLONE_PARENT_SETTID and CLONE_CHILD_CLEARTID, the three flags that wire the kernel into this structure
Thread Stacks and Stack Guards — the stackblock / guardsize fields, the stack cache, and the guard page as distinct from the stack_guardcanary
pthread Synchronization and Futexes — the robust_head robust-futex list the kernel walks on thread death, and the multiple_threads fast path