Skip to content

tls: the pthread key of the thread local slots is created once - #49

Merged
Jarred-Sumner merged 3 commits into
bun-dev3-v2from
claude/pthread-key-create-race
Sep 15, 2026
Merged

Jarred-Sumner merged 3 commits into
bun-dev3-v2from
claude/pthread-key-create-race

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Where a thread local is a pthread key (macOS, MI_TLS_MODEL_PTHREADS), the key is created when it is first set: mi_pthread_key_set looks at the key, and when it is not there yet _mi_pthread_key_create creates one into it and stores the value under it. No synchronization between the look and the create.

The key of the thread local slots (threadlocal.c: the theaps of the heaps besides the main one hang off it) is first set when such a heap is first used, and a program can do that on many threads at once. Two threads both find no key, both create one, the second replaces the first in the variable, and the first thread's slots are now under a key that nobody reads any more:

  • a debug build stops in heap.c:mi_heap_init_theap, assertion: "theap == (mi_theap_t*)_mi_thread_local_get(heap->theap)". That is the failure of test-park-handoff-eager on the macOS Intel runners, seen twice (on bun-dev3-v2 itself, run 34803968831, and on profile: a theap starts over with each mi_profiler_start #47's first run): it is not the park code, the test that fails is test_exit_while_swept_with_dyn_tls, whose eight threads each do mi_heap_new + mi_heap_malloc and are the first in that process to use another heap than the main one;
  • a release build has no assertion: the thread makes a second theap for its heap, the first one and its pages stay until the thread ends, and a pthread key leaks.

The change

  • _mi_pthread_key_create_once creates the key aside and stores it with a compare and swap; a thread that was not first deletes its key and uses the one that was (also when its own pthread_key_create fails). mi_pthread_key_set calls it instead of _mi_pthread_key_create, which stays as it is for the two keys that _mi_tls_slots_init creates under its own once.
  • The key of mi_define_thread_local is an _Atomic(pthread_key_t), as _mi_theap_default_key in prim-tls.c is: read with a relaxed load where a value is read, with acquire where one is set (about once a thread).

Platforms with real thread locals are not affected (the macro defines a plain mi_decl_thread variable there).

Test

test-tls-key-race: one process, eight threads that make their heaps, wait for each other, and allocate from them at the same moment. A thread that lost its slots does not notice (it makes new ones and a second theap for its heap), so the test counts the theaps that were made: two a thread. A debug build has a hook for it like the other mi_debug_stall_in_* ones, mi_debug_stall_in_pthread_key_create: the first thread that comes to create a key waits for a second one, so both have found no key. With MI_TLS_MODEL_PTHREADS and MI_DEBUG=FULL:

this PR "8 threads made 16 theaps, 0 of them saw its heap change; 2 created the key at the same time", 100 of 100 runs (3 now and then)
with the last key winning, as before MI_DEBUG=FULL: stops on heap.c:84 or page.c:259, 12 of 12; MI_DEBUG=ON: "made 17 theaps", 12 of 12

A release build has no hook and only the natural window, a few instructions wide; to see the unpatched code fail there I put a usleep(2000) in front of pthread_key_create (not part of this PR): a forking version of the test, 8 threads in each of 50 children, failed in 50 of 50 on the assertion above, and in 0 of 100 with the compare and swap and the same sleep; without the sleep 0 of 400 either way on a 64 core Linux machine (the macOS Intel runners hit it twice in some dozens of runs).

Locally, all of ctest: release 34 of 34, debug (MI_DEBUG=FULL) 35 of 35, debug as C++ with MI_TLS_MODEL_PTHREADS 35 of 35, MI_TLS_MODEL_PTHREADS debug 35 of 35 and release 34 of 34; the test alone under the thread sanitizer with and without MI_TLS_MODEL_PTHREADS: no report.

Where a thread local is a pthread key (macOS, MI_TLS_MODEL_PTHREADS), the
key is created when it is first set: `mi_pthread_key_set` looked at the key,
and when it was not there yet `_mi_pthread_key_create` created one into it
and stored the value under that. The key of the thread local slots
(`threadlocal.c`, the theaps of the heaps besides the main one) is first set
when such a heap is first used, which a program can do on many threads at
once: two threads both find no key and both create one, the second replaces
the first, and the first thread's slots are under a key that nobody reads
any more. Its next `_mi_thread_local_get` finds nothing: a debug build stops
in `heap.c:mi_heap_init_theap` ("theap == _mi_thread_local_get(heap->theap)",
seen twice in test-park-handoff on the macOS Intel runners, whose
test_exit_while_swept_with_dyn_tls is eight threads that do this first), a
release build makes a second theap for the heap and leaves the first one,
with its pages, until the thread ends, and a key leaks.

`_mi_pthread_key_create_once` creates the key aside and stores it with a
compare and swap; a thread that was not first deletes its key and uses the
one that was.

test-tls-key-race: 200 child processes (there is one first time in a
process), eight threads each that make their heaps, wait for each other and
allocate from them at once. The window is a few instructions: 0 of 400
rounds hit it on a 64 core Linux machine with MI_TLS_MODEL_PTHREADS, fixed
or not. With 2 ms in front of pthread_key_create: 50 of 50 rounds stop on
the assertion before, 0 of 100 after.
Review of the previous commit.

- The key of `mi_define_thread_local` is an `_Atomic(pthread_key_t)`, read
  with a relaxed load, as `_mi_theap_default_key` in prim-tls.c is: no cast
  of a plain variable to an atomic, and nothing for a thread sanitizer to
  report.
- When `pthread_key_create` fails, the key that another thread made
  meanwhile is used if there is one.
- `mi_debug_stall_in_pthread_key_create` (MI_DEBUG, as the other stall
  hooks): the first thread that comes to create a key waits for a second
  one. test-tls-key-race sets it: one process, eight threads, no fork (the
  children of the first version could not run under the thread sanitizer,
  and did not tell fixed from not fixed). With the last key winning, as
  before the previous commit, it stops on the assertions of
  `heap.c:mi_heap_init_theap` or `page.c:mi_page_is_valid_init` every time.
…re it is set

A thread that lost its slots makes new ones and a second theap and goes on
with those: what the test could see of it were the assertions of a build
with MI_DEBUG >= 2, and those did not fire every time. It counts the theaps
that were made now: two a thread, three for one that lost its slots (12 of
12 with the last key winning and MI_DEBUG=1; with MI_DEBUG=FULL the
assertions stop it 12 of 12). With the hook, two or more threads have to
have been in the creation.

`mi_pthread_key_set` reads the key with acquire: a thread that sees the key
also sees what `pthread_key_create` did for it.
@Jarred-Sumner
Jarred-Sumner merged commit fa14240 into bun-dev3-v2 Sep 15, 2026
23 checks passed
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Cost

Measured after the merge, for the record: f3171b42 (this PR's head) against its base dbefe024. Release builds with clang 21, perf stat -e instructions:u, pinned to the same 8 cores, the two builds interleaved, medians of 7 runs (5 for the default TLS rows), range in brackets where it is not zero.

What changed in the generated code

  • Default Linux build (real TLS, the changed helpers are not compiled): the disassembly of every object in libmimalloc.a is the same byte for byte, but for libc.c.o, which gains the one new function _mi_pthread_key_create_once (+176 bytes of text, 241418 -> 241594 for the whole library; nothing calls it in this configuration).
  • -DMI_TLS_MODEL=PTHREADS on Linux x86-64: only threadlocal.c.o (2315 -> 2283 bytes of text) and libc.c.o (+176) differ; alloc.c.o, heap.c.o, theap.c.o, init.c.o, page.c.o and the rest are the same.
    • _mi_thread_local_get and mi_thread_local_get_regular (what mi_heap_malloc on a heap that is not the main one goes through for every call) are the same instruction for instruction: cmp; jne; mov key(%rip),%edi; jmp pthread_getspecific. The relaxed atomic load is the same mov.
    • _mi_thread_local_set is three instructions shorter (it no longer passes a destructor argument along); the acquire load of the key is a plain mov on x86.
    • _mi_thread_locals_done (process teardown) uses xchg where it had a load and a store.
    • No fence anywhere.
  • arm64 macOS (where the pthread path is the default; threadlocal.c, libc.c and heap.c compiled with --target=arm64-apple-macos13 -O3, as C and as C++):
    • heap.c is the same file of assembly before and after.
    • _mi_thread_local_get is the same instruction for instruction: cmp x0,#1; b.ne; adrp; ldr x0,[x8,key@PAGEOFF]; cbz; b _pthread_getspecific. The relaxed load is the same ldr.
    • Where a value is set (_mi_thread_local_set, mi_thread_local_set_expand, _mi_thread_locals_thread_done: once per thread and heap, and at thread exit), the key is now read with add x8,x8,key@PAGEOFF; ldapr x0,[x8] where it was one ldr x0,[x8,key@PAGEOFF]: one more instruction, and no dmb. In the two functions that read the key and then set it, the adrp is repeated after the call in between where the base kept the page address in a callee-saved register; neither is in a loop.
    • _mi_thread_locals_done uses swp where it had ldr + str.

Instructions (G = 10^9 user instructions)

-DMI_TLS_MODEL=PTHREADS base dbefe024 head f3171b42
allocator micro (50 M random malloc/free of 16..1039 bytes) 2.5686 2.5686 0.000%
20 M x mi_malloc(128 KiB) + mi_free 5.4713 5.4713 0.000%
8 threads x 100 k x (mi_heap_new, 4 x mi_heap_malloc, mi_free, mi_heap_destroy) 24.4511 [24.4445-24.4638] 24.4513 [24.4462-24.4547] +0.001%
the same on one thread 2.9425 2.9422 -0.010% (3 instructions per iteration)
8 threads x 10 M x (mi_heap_malloc + mi_free) on a heap of the thread's own 6.4870 6.4870 0.000%
the same on one thread 0.8112 0.8112 0.000%
default TLS (Linux) base head
allocator micro 1.9932 1.9932 0.000%
128 KiB pair 5.0313 5.0313 0.000%
heaps, 8 threads 23.8768 [23.8696-23.8841] 23.8750 [23.8699-23.9151] -0.008%
heaps, 1 thread 2.8699 2.8699 0.000%
mi_heap_malloc + mi_free, 1 thread 0.5509 0.5509 0.000%

Bun (release build of the same tree, the mimalloc commit the only difference; Linux x64, so the changed path is not compiled)

pinned to dbefe024 pinned to f3171b42
bun -e 1 (9 runs) 10.01 M [10.00-10.03] 10.02 M [10.01-10.03]
JSON.stringify + JSON.parse loop (5 runs) 4789.8 M [4787.1-4794.4] 4789.4 M [4787.0-4794.4]
allocation loop (5 runs) 5771.6 M [5742.9-5804.2] 5792.8 M [5783.7-5817.3] (ranges overlap; this one moves by that much between runs of one binary)

Not measured: a run on Apple hardware. What is above for arm64 is the generated code, not a timing.

Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Sep 15, 2026
, #51): recently used large pages are left alone for a while (#42808)

## What

`MIMALLOC_COMMIT` 69050cf3 → 6df7aa13, the head of `bun-dev3-v2`.
Relative to main's pin that is three fork PRs:

- **oven-sh/mimalloc#50**, what this PR is about: see below.
- **oven-sh/mimalloc#49**: the pthread key of the allocator's
thread-local slots is created once when several threads first use a
second heap at the same time (macOS; the code is not compiled on Linux).
Measured there: every hot path is the same instruction for instruction.
- **oven-sh/mimalloc#47**: a profiler that is started again starts every
thread over. Only reachable through the sampling-profiler hooks, which
nothing on main calls yet; #42626 (`Bun.pprof.heap`) bumps the pin to
that commit for its own sake. Whichever of the two PRs lands second
keeps the newer commit, which is this one.

Since #42668 the idle sweep hands back the free blocks of the
allocator's 4 MiB pages (buffers of 96 KiB and up) two sweeps after the
page was last allocated from, which is 100–200 ms. A server sits idle
between two requests for longer than that, so it took the buffers of
every request out of discarded memory again: a page fault for each 4 KiB
of them, on every request. About half of that was free blocks in a page
that still had a buffer in use; the other half was pages with no buffer
in use at all, which were freed outright and made anew for the next
request.

oven-sh/mimalloc#50: a large page (its free blocks, or all of it) is
left alone until it has not been allocated from for 256 epochs of the
sweep, for up to 4 MiB process-wide, the pages that were used last
first. There is no timer in this: an epoch ends only when some thread is
swept. `MIMALLOC_PURGE_HOLES_LARGE_FLOOR=0` is the behaviour before (a
bare number there is bytes: write `4MiB`, not `4096`).

## What it costs

- A process in which no thread ever parks again keeps up to 4 MiB of
large pages that it would have given back. Measured with the allocator
alone: 24 blocks of 300 KiB, 20 freed, a few short parks and then one
for good: 4460 KiB anonymous at every reading for 70 s against 1460 KiB
before (3.0 MB of the 6 MB freed; a page stays or goes whole). The
allocator's scavenger thread wakes for its 30 s safety timeout as before
and for nothing else.
- An idle server holds 0.2–1.0 MB more ten seconds after the load (3 MB
after 512 KiB bodies) and the same as before a minute after.
- A large application parked at a prompt: 1 MB.

## Numbers

Linux x64, release builds of this tree, the mimalloc commit the only
difference.

`Bun.serve` that answers a request with its own body, one connection, 5
requests a second for 20 s. Minor faults of the server
(`/proc/<pid>/stat`) and kernel instructions (`perf stat -e
instructions:k -p`), per request:

| body | main: faults | kernel instr | this PR: faults | kernel instr |
|---|---|---|---|---|
| 128 KiB | 65.4 | 627 K | 2.6 | 208 K |
| 256 KiB | 123.9 | 1031 K | 2.5 | 375 K |
| 512 KiB | 309.1 | 2331 K | 14.1 | 898 K |
| `hello` | 1.1 | 128 K | 1.1 | 114 K |

(No latencies: the machine was busy with other work and the median of
one binary moved more from run to run than the two differ. At 512 KiB it
shows through: 2.60 ms → 2.08 ms.)

Idle afterwards, anonymous RSS of the server in KiB at +10 s / +70 s:
128 KiB 3128 / 2908 → 4044 / 2940; 256 KiB 3248 / 3056 → 3280 / 3044;
512 KiB 3564 / 3356 → 6624 / 3376; `hello` 2936 / 2808 → 2784 / 2780. So
it holds up to 3 MB more ten seconds after the load and the same a
minute after.

A large `--compile`d CLI application that sits at a prompt (anonymous +
file-backed RSS in MB; 10 minutes at the first prompt, a scripted
session of 20 turns, 10 minutes parked; two runs each, all four started
together):

| | main | this PR |
|---|---|---|
| first prompt + 30 s | 121+95 / 122+95 | 124+67 / 124+95 |
| + 2 min | 100+31 / 100+38 | 101+36 / 101+36 |
| + 5 min | 96+32 / 96+38 | 97+36 / 97+37 |
| + 10 min | 96+32 / 96+39 | 97+37 / 97+38 |
| after the session + 30 s | 258+87 / 252+79 | 265+87 / 268+88 |
| + 2 min | 190+43 / 183+44 | 197+43 / 195+44 |
| + 5 min | 165+44 / 160+45 | 171+44 / 170+45 |
| + 10 min | 171+51 / 166+53 | 176+52 / 176+53 |

At the prompt that is 3 MB more for the first minute, 1 MB at two
minutes and after. After the session these four runs are 5–10 MB apart;
the same comparison with one binary and the floor switched by the
environment (`…LARGE_FLOOR=0`) came out at 171 / 161 against 168 / 173
at + 5 min, so what the floor itself keeps there (3–4 MB for two
minutes) is inside what two runs of one configuration differ by.

Instructions (`perf stat -e instructions:u`, pinned, interleaved, median
[range]):

| | main | this PR |
|---|---|---|
| `bun -e 1` (9 runs) | 10.05 M [10.04–10.07] | 10.05 M [10.05–10.07] |
| `JSON.stringify` + `JSON.parse` loop (5) | 4788.6 M [4786.5–4793.8] |
4789.3 M [4788.8–4796.6] |
| allocation loop (5) | 5760.0 M [5753.6–5766.7] | 5799.9 M
[5758.2–5824.1] |
| the allocator alone: 50 M random `malloc`/`free` | 1.9932 G | 1.9932 G
|
| the allocator alone: 20 M × `malloc(128 KiB)` + `free` | 5.0313 G |
5.0313 G |

(The allocation loop moves by that much between runs of one binary; its
ranges overlap.)

## Test

`test/js/bun/jsc/heapStats-mimalloc.test.ts`: a `Bun.serve` child that
echoes 256 KiB bodies, requests 50 ms apart with the sweep's epoch
shortened to 10 ms and the collector looking every 100 ms, minor faults
per request read from `/proc`. 2.3–2.4 with this pin, 130–131 with
main's (the bound is 20). Linux only, not ASAN.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant