Fix multithreaded deadlock between the body cache and the compiler lock - #143
Merged
ChrisRackauckas merged 1 commit intoAug 20, 2026
Conversation
`_lookup_body` is called from the `generated_callfunc` expansion, so it runs while the calling thread holds Julia's compiler lock. It took `_cache_lock`, while `_cache_body` held `_cache_lock` and could block on the compiler lock (inserting into the cache compiles a specialization) — a lock-order inversion that hangs both threads with the spinlock burning CPU. Reads are now lock-free. The cache is a stack of `Dict`s that are never mutated after publication, swapped in through an `@atomic` field; writers still take `_cache_lock`. Levels merge on the logarithmic method so an insert copies O(log n) entries rather than the whole map. The existing threaded test could not catch this because the suite runs single-threaded; it now runs in a subprocess with `--threads=8` and a watchdog. Fixes SciML#141 Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas
marked this pull request as ready for review
August 20, 2026 07:59
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.
Please ignore until reviewed by @ChrisRackauckas.
Fixes #141.
What changed and why
_lookup_bodyis called from the expansion of thegenerated_callfunc@generatedmethod, so it runs while the calling thread holds Julia's compilerlock — and it took
_cache_lock. Meanwhile_cache_bodyholds_cache_lockwhile inserting into the cache
Dict, and that insert can compile aspecialization (
setindex!→rehash!→ codegen) and block on the compilerlock. Classic lock-order inversion; because
_cache_lockis aSpinLock, thehung threads sit at 100% CPU.
Reads are now lock-free. The cache is a stack of
Dicts, newest first, none ofwhich is mutated once published; a reader takes the stack with a single atomic
load. Writers still serialize on
_cache_lockand publish a replacement stack.Copying the whole map per insert would be O(n²) for a package that generates
thousands of functions, so levels merge on the logarithmic method (a level merges
down once it reaches half the next level's size): sizes more than double going
down the stack, bounding both a reader's probes and an insert's copying at
O(log n).
The cache object stored in each initialized module changes from
Dict()to aninternal
RuntimeGeneratedFunctions._BodyCache. That name(
#_RuntimeGeneratedFunctions_cache) is not public API, but it is a shape changefor anything reaching into it.
Failing before / passing after
Reproducer from the issue, on Julia 1.10.11 (also reproduces on 1.13.0-rc3):
Before (
master,--threads=8,timeout -s KILL 90; exit 137 = killed while hung):After (same command, 5 consecutive runs):
The regression test discriminates
The existing threaded test ran in-process, and the suite runs single-threaded, so
it never exercised this. It now runs
test/shared/threaded_rgf.jlin asubprocess with
--threads=8and a 600 s watchdog.With the fix reverted (
git stash push src/RuntimeGeneratedFunctions.jl) and thenew test in place,
include("test/core_tests.jl")on Julia 1.10:CI Core also runs on macOS and Windows; to check it discriminates on a
low-core-count runner,
taskset -c 0,1(2 cores) on the unfixed code hangs 3/3at
--threads=8and 3/3 at--threads=2, and passes 3/3 at both with the fix.Verification
Run locally on this branch:
Performance
The cost is on inserts. Cache-only microbenchmark (
_cache_bodyon n distinctids, then
_lookup_bodyon each), Julia 1.10:Scaling is n log n, not n²: 10× the entries costs 12× the time. End-to-end
RuntimeGeneratedFunctionconstruction (which is dominated by SHA-1 hashing theexpression, and in real use by codegen):
CI
All 16 checks green: Core on {ubuntu, macOS, Windows} × {lts, 1, pre}, QA, Downgrade,
Documentation, Runic, typos. My local runs were Linux-only, so CI is what covers the
new subprocess test's
kill(proc, Base.SIGKILL)watchdog on macOS and Windows.Not verified
:acquire/:releaserather thanrelying on x86 store ordering, so this should be fine, but it was not tested.
Worth pushing back on
Threads.SpinLock. Now that readers never take it, aReentrantLockwould be safe and would stop other writers burning cores whileone of them compiles under the lock. I left it alone to keep the diff to the
deadlock, but it is a reasonable follow-up.
changes, which is a patch-level change only because that name is not public
API.
🤖 Generated with Claude Code
https://claude.ai/code/session_01B9uAJ7kQsHJtp7Yb2qdFVV