Skip to content

functools.lru_cache critical section locks self but _LockHeld dict APIs assert self->cache is locked #148180

@devdanzin

Description

@devdanzin

Crash report

What happened?

On a debug free-threaded build, functools.lru_cache crashes with a critical section assertion failure when gc.get_objects() is called between cache uses.

bounded_lru_cache_wrapper (line 1498) acquires Py_BEGIN_CRITICAL_SECTION(self), locking the lru_cache object's mutex. It then calls bounded_lru_cache_get_lock_held (line 1325) which calls _PyDict_GetItemRef_KnownHash_LockHeld on self->cache. The _LockHeld variant asserts that the dict's per-object mutex is held (_Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(mp) at dictobject.c:1254), but only self's mutex is held — self and self->cache have different mutexes.

In 3.15 we have this abort message:

Assertion "(cs != ((void*)0) && cs->_cs_mutex == mutex)" failed: Critical section of object is not held

This was introduced in gh-131757 (PR #131758), which split the critical section to allow the cached function to execute concurrently. The same pattern was extended in gh-132641 (PR #133787) which added more _LockHeld calls following the same assumption.

On non-debug builds the assertion is compiled out and the code happens to work in practice (no actual contention in the reproducer), but the lock contract is violated.

Reproducer

Requires a debug free-threaded build (e.g., --with-pydebug --disable-gil).

from functools import lru_cache
import gc

@lru_cache(maxsize=32)
def get_pep(num):
    return type('', (), {})()

for n in 8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991:
    pep = get_pep(n)
    print(n, pep)

objs = gc.get_objects()
print(dir(get_pep))

for n in 8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991:
    pep = get_pep(n)
    print(n, pep)

Backtrace on 3.14 (trimmed):

python: ./Include/internal/pycore_critical_section.h:226: void _PyCriticalSection_AssertHeld(PyMutex *): Assertion `cs != NULL && cs->_cs_mutex == mutex' failed.
[...]
#5  _PyCriticalSection_AssertHeld (mutex=...) at pycore_critical_section.h:226
#6  _Py_dict_lookup (mp=...) at Objects/dictobject.c:1254
#7  _PyDict_GetItemRef_KnownHash_LockHeld (...) at Objects/dictobject.c:2420
#8  bounded_lru_cache_get_lock_held (...) at Modules/_functoolsmodule.c:1325
#9  bounded_lru_cache_wrapper (...) at Modules/_functoolsmodule.c:1499

Backtrace on 3.15:

./Include/internal/pycore_critical_section.h:249: _PyCriticalSection_AssertHeldObj: Assertion "(cs != ((void*)0) && cs->_cs_mutex == mutex)" failed: Critical section of object is not held
Enable tracemalloc to get the memory block allocation traceback

object address  : 0x7bffb670ccd0
object refcount : 2
object type     : 0x555556749840
object type name: dict
object repr     : {8: <functools._lru_list_elem object at 0x7bffb65701d0>, 290: <functools._lru_list_elem object at 0x7bffb6570240>, 308: <functools._lru_list_elem object at 0x7bffb65702b0>, 320: <functools._lru_list_elem object at 0x7bffb6570320>, 218: <functools._lru_list_elem object at 0x7bffb6570390>, 279: <functools._lru_list_elem object at 0x7bffb6570400>, 289: <functools._lru_list_elem object at 0x7bffb6570470>, 9991: <functools._lru_list_elem object at 0x7bffb65704e0>}

Fatal Python error: _PyObject_AssertFailed: _PyObject_AssertFailed
Python runtime state: initialized

Stack (most recent call first):
  File "/home/danzin/crashers/yifei_01.py", line 15 in <module>

Program received signal SIGABRT, Aborted.
Download failed: Invalid argument.  Continuing without source file ./nptl/./nptl/pthread_kill.c.
__pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
warning: 44     ./nptl/pthread_kill.c: No such file or directory
(gdb) bt
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
#1  __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:89
#2  __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=6) at ./nptl/pthread_kill.c:100
#3  0x00007ffff7c45e2e in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#4  0x00007ffff7c28888 in __GI_abort () at ./stdlib/abort.c:77
#5  0x000055555606dd54 in fatal_error_exit (status=status@entry=-1) at Python/pylifecycle.c:3389
#6  0x000055555606da1b in fatal_error (fd=fd@entry=2, header=header@entry=1, prefix=prefix@entry=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed",
    msg=msg@entry=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed", status=status@entry=-1) at Python/pylifecycle.c:3612
#7  0x0000555556069730 in _Py_FatalErrorFunc (func=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed",
    msg=0x555556405360 <__func__._PyObject_AssertFailed> "_PyObject_AssertFailed") at Python/pylifecycle.c:3635
#8  0x00005555557fc43b in _PyObject_AssertFailed (obj=obj@entry=0x7bffb670ccd0, expr=<optimized out>, msg=<optimized out>, file=<optimized out>, line=line@entry=249,
    function=<optimized out>) at Objects/object.c:3259
#9  0x0000555555bb7711 in _PyCriticalSection_AssertHeldObj (op=0x7bffb670ccd0) at ./Include/internal/pycore_critical_section.h:247
#10 _Py_dict_lookup (mp=0x7bffb670ccd0, key=key@entry=0x55555693c4f8 <_PyRuntime+30776>, hash=hash@entry=8, value_addr=value_addr@entry=0x7fffffff9d00) at Objects/dictobject.c:1305
#11 0x0000555555bbbc61 in _PyDict_GetItemRef_KnownHash_LockHeld (op=0x196f39, key=0x196f39, key@entry=0x55555693c4f8 <_PyRuntime+30776>, hash=6, hash@entry=8,
    result=result@entry=0x7fffffff9e80) at Objects/dictobject.c:2480
#12 0x00005555562ed221 in bounded_lru_cache_get_lock_held (self=0x7bffb6826c90, args=0x7bffb69bb290, kwds=0x0, result=<optimized out>, key=<optimized out>, hash=<optimized out>)
    at ./Modules/_functoolsmodule.c:1416
#13 bounded_lru_cache_wrapper (self=0x7bffb6826c90, args=0x7bffb69bb290, kwds=0x0) at ./Modules/_functoolsmodule.c:1590
#14 0x0000555555ac6b5d in _PyObject_MakeTpCall (tstate=0x555556997d60 <_PyRuntime+405664>, callable=0x7bffb6826c90, args=0x7fffffffa048, nargs=1, keywords=0x0) at Objects/call.c:242
#15 0x0000555555ea9e3b in _Py_VectorCallInstrumentation_StackRefSteal (callable=..., arguments=arguments@entry=0x7e8ff6fe0290, total_args=total_args@entry=1, kwnames=kwnames@entry=...,
    call_instrumentation=false, frame=frame@entry=0x7e8ff6fe0220, this_instr=0x7bffb6405394, tstate=0x555556997d60 <_PyRuntime+405664>) at Python/ceval.c:775
#16 0x0000555555ee754d in _PyEval_EvalFrameDefault (tstate=<optimized out>, frame=<optimized out>, throwflag=<optimized out>) at Python/generated_cases.c.h:1841
#17 0x0000555555ea8d04 in _PyEval_EvalFrame (tstate=0x555556997d60 <_PyRuntime+405664>, frame=0x7e8ff6fe0220, throwflag=0) at ./Include/internal/pycore_ceval.h:118
#18 _PyEval_Vector (tstate=tstate@entry=0x555556997d60 <_PyRuntime+405664>, func=func@entry=0x7bffb64c3e30, locals=locals@entry=0x7bffb670ca90, args=args@entry=0x0,
    argcount=argcount@entry=0, kwnames=kwnames@entry=0x0) at Python/ceval.c:2179
#19 0x0000555555ea80db in PyEval_EvalCode (co=co@entry=0x7bffb6405210, globals=globals@entry=0x7bffb670ca90, locals=locals@entry=0x7bffb670ca90) at Python/ceval.c:686
#20 0x00005555560c6ca0 in run_eval_code_obj (tstate=tstate@entry=0x555556997d60 <_PyRuntime+405664>, co=co@entry=0x7bffb6405210, globals=0x7bffb670ca90, locals=locals@entry=0x7bffb670ca90)
    at Python/pythonrun.c:1369

Possible fix

Either lock both objects:

// Line 1498, 1511: lock both self and self->cache
Py_BEGIN_CRITICAL_SECTION2(self, self->cache);

Or use the regular (self-locking) dict APIs instead of _LockHeld variants for the get path.

The initial crash and reproducer were found by @zhuyifei1999. Root cause analysis and issue draft done with assistance from Claude Code.

CPython versions tested on:

3.15, CPython main branch, 3.14

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.15.0a7+ free-threading build (heads/main:4d0e8ee649c, Mar 29 2026, 23:20:29) [Clang 21.1.2 (2ubuntu6)]

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions