mi_heap_delete: detach the theaps before the pages are moved and free them after - #25
Closed
robobun wants to merge 4 commits into
Closed
Conversation
…oncurrent mi_heap_delete can free the heap mi_heap_delete claims each page of the heap, re-points page->heap to the main heap, and then frees the heap struct. A mi_free from another thread runs concurrently with that. Two places on its path read page->heap at a point where the deleter does not wait for them: - mi_stat_free (MI_STAT>0) read page->heap->subproc before it owns the page. The heap struct can be freed between the two loads, and the free list link is written over heap->subproc (its first field), so the next load is garbage and subproc->theap_meta faults. This is the SIGSEGV in test-heap-mt (heap-free-during-delete-overlap) under load. The stats now find the subproc through the page's arena, which is constant for the lifetime of the page (and through the current subproc for an OS allocated page), and the meta theap test compares the page's thread id with MI_THREADID_DETACHED directly. - mi_arenas_page_free_prim read page->heap->subproc after it cleared the page's bit in the heap's arena_pages. That bit is what the deleter waits on, so the heap can be gone by then. Read the subproc once, before the bit is cleared. Document the rule on mi_page_heap and on the page->heap field.
…e is freed mi_bitmap_clear updates the chunkmap of the bitmap after it clears the bit. The bit is what a concurrent mi_heap_delete waits on before it frees the heap and its arena_pages (mi_heap_visit_page_at), so that chunkmap update could touch a freed arena_pages. Add mi_bitmap_clear_no_chunkmap, which leaves the chunkmap bit set (allowed: it means the chunk may have bits set, and the pages bitmap is only ever visited, never searched), and use it in mi_arenas_page_free_prim. Narrow the comment on mi_page_heap to what the delete actually waits for.
…em after mi_heap_delete (and mi_heap_destroy) freed the theaps of the heap before it walked the pages, and the detach marked a theap by clearing theap->tld. A thread that used the heap before still reaches its theap for the heap through the heap's thread local while it frees blocks of the heap during the delete (_mi_page_associated_theap_peek on the reclaim and re-abandon paths of mi_free_try_collect_mt). That theap is then detached, with a NULL tld that the callers dereference, or already freed. Now the detach clears theap->heap instead (as _mi_tld_detach_theaps does on thread exit), which is what the peek compares, so a free that runs after the detach does not get the theap anymore. The theap struct and its tld stay valid until after the page walk, for a free that obtained the theap just before the detach: the walk waits for such a free while it owns the page. The statistics are still merged before the walk, so the page statistics of the heap are unchanged. _mi_heap_theap_peek returns NULL for a detached theap as well instead of asserting. test-heap-mt gets a variant where the pages are abandoned (the allocating thread exited) and every freer allocated from the heap once. It crashed in the first iteration before (mi_theap_matches_thread through _mi_arenas_page_try_reabandon_to_mapped).
Armed after pthread_create, the thread can have exited before the store and then never parks, and the wait for it spins forever. Seen once as a 1500s ctest timeout under a loaded ctest -j4.
Collaborator
Author
|
#26 is now stacked on this PR and adds only a regression test (test/test-heap-aba.c) for the theap cache ABA that the |
This was referenced Aug 22, 2026
test-heap-aba: regression test for the theap cache ABA on a reused heap address (stacked on #25)
#26
Closed
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Aug 22, 2026
…en-sh/mimalloc#25, CI stand-in) The dev3 sync (#37367) carried upstream 36e8fc33, which dropped the `theap->heap = NULL` store from the heap-delete detach path. A thread's one-entry theap cache is keyed only by that pointer, so a heap created at the address of a destroyed one matched the stale entry and the thread allocated from a detached theap whose pages had gone back to the arena. Carry the store as a patch on the current pin so CI builds and tests it on every platform. oven-sh/mimalloc#25 owns the change in the fork and oven-sh/mimalloc#26 adds the regression test; this becomes a pin bump once they are merged, and the patch is removed then.
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #24 (its two commits come first, merge it first). This PR is the last two commits.
Problem
mi_heap_deletefrees the theaps of the heap before it walks the pages (heap.c:236), and the detach marks a theap withtheap->tld = NULL(theap.c:672). A thread that used the heap before still gets that theap from the heap's thread local while it frees blocks during the delete (_mi_page_associated_theap_peek, fromfree.c:369andarena.c:1269), and then readstheap->tld, or a freed theap.test-heap-mtvariant (pages abandoned by an exited allocator thread, freers that allocated from the heap once) segfaults in its first iteration on Do not dereference page->heap on the cross-thread free path while mi_heap_delete can free the heap #24:mi_theap_matches_thread(internal.h:714) <-_mi_page_associated_theap_peek<-_mi_arenas_page_try_reabandon_to_mapped<-mi_free. Found by the self-review of Do not dereference page->heap on the cross-thread free path while mi_heap_delete can free the heap #24.Fix
theap->heapinstead (under the tld lock, after the unlink), as_mi_tld_detach_theapsdoes on thread exit. That is the field the peek compares, so a free that runs after the detach gets NULL and leaves the page abandoned.mi_heap_deleteandmi_heap_destroydetach (and merge the statistics, as before), then walk the pages, and free the theap structs last. A free that got the theap just before the detach still owns its page and the walk waits for it, so the struct and itstldstay valid for as long as that free can use them._mi_heap_theap_peekreturns NULL for a detached theap instead of asserting. The statistics of a deleted or destroyed heap do not change.ctestin Debug and Release. The second commit fixes an arming race intest-fork-user-heapseen during that (notes).Background
heap->theap, through which a thread finds its theap for the heap. A cross-thread free uses it to reclaim an abandoned page or to put a page back on the abandoned map.tld->theaps) and the heap's (heap->theaps). The detach takes it off the thread's list under the thread's lock. The delete frees the heap's list.theap->heap == NULLalready means "not usable" here (mi_theap_is_initialized).Notes
Fail before, on the #24 tree with only the test change (Debug,
MI_DEBUG_FULL): 3 of 3 runs segfault right after the variant starts, with reclaim on free disabled as in the committed test:With reclaim on free at its default the same run crashes through
mi_abandoned_page_try_reclaim(free.c:369) instead. The committed test disables reclaim on free (and restores it afterwards) on purpose: a freer that reclaims a page holds it like an allocating thread, and the delete moves a held page without waiting for its thread, so a holder must not free into its pages during the delete. That is the existing contract (issue 3 in the notes of #24) and this PR does not change it.theap->tldis NULL rather than freed in the crash because the thread's cached theap slot holds a reference (_mi_theap_cached_set); a thread whose cache moved on to another heap reads a freed theap instead.Pass after:
./mimalloc-test-heap-mt 500, 3 of 3 runs, 4 variants. Loaded: 6 copies pinned to 2 CPUs, 500 iterations, 3 rounds, 0 crashes in 18 runs. TSAN (./mimalloc-test-heap-mt 30): 2 usable runs, 0 reports. ASAN (MI_TRACK_ASAN=ON):test-heap-mt 100andtest-heap-delete-raceclean.test-heap-delete-race(the delete against a thread that is still exiting, deliberately outside the contract) passes in Debug, Release, TSAN (no crash, same kind of stats reports as on the unfixed tree, 34 vs 43) and ASAN. The new variant also exercises the second site of #24 with every page abandoned, in Debug and Release.ctestDebug: 20 of 21 pass (test-purge-holesunformed-tail, same as on the unfixed tree, see #24). Release: 20 of 20. During one Debug run underctest -j4,test-fork-user-heaphit the 1500 s timeout incase_bbefore it forked: the stall hook is armed afterpthread_create, and the thread had already exited (the log stops after thecase_bbanner, the alarm only covers the child). It does not involve the code changed here (case_ahad just run 200 deletes in forked children). The second commit arms the hook before the thread exists. 18 further runs of the test, 12 of them with 2 CPUs for 12 copies, passed with and without that commit, so it is a rare race of the test.Ordering argument for the free-after-walk: a free can only obtain a theap of the heap through
heap->theapwhile it owns a page whosepage->heapis still this heap. The walk moves every such page, and for an abandoned page it waits until the owner releases it (mi_heap_visit_page_at). When the walk returns, no free holds a page of the heap, so nothing can still be using the theaps, and nothing can obtain them anymore because the detach happened before the walk. The exception is an OS allocated page that a freer owns: the walk skips it (issue 2 in the notes of #24), so that freer can still use the theap after the walk. It also uses the freed heap itself on that path, so OS pages are not made worse or better by this PR, they need the design of their own noted in #24. The exiting-thread protocol is unchanged:_mi_heap_detach_theapsstill takes the thread's lock, andmi_thread_theaps_donewalks its list under that lock, so a theap is either still in that list with its heap set, or already unlinked.Two things the new window (detached, not yet freed) does not cover, on purpose. A freer that obtained the theap just before the detach and then bumps a counter on it (
pages_reabandon_full,pages_abandoned) does so after the statistics were merged, so those few increments are not carried into the heap. Before this PR that freer crashed. Merging a second time after the walk would make them exact at the cost of a second pass over the 4 KiB stats struct on every destroy (bun destroys a heap per transpile), so I left it. A thread that allocates from the heap while another thread deletes it is outside the contract:_mi_heap_theapthen still hands out the detached theap through the heap's slot, as it handed out the freed one before.The destroy path, which bun runs on every arena reset, does the same work as before in a different order, plus one uncontended acquire of
heap->theaps_lock. The destroying thread's cached theap survivesmi_heap_free_theapson the unfixed tree as well (the cache holds a reference) and is evicted by the next allocation from another heap, whichmi_heap_newalways is. I checked that with a destroy-then-new probe on both trees: the new heap got the old address 1000 of 1000 times and allocations from it went to a fresh theap on both.src/static.ccompiles as C++ with clang 21 without warnings in the debug and release configurations bun uses.