Skip to content

fix: wait for the cache lock detached in every method - #117

Merged
awolverp merged 2 commits into
awolverp:mainfrom
greenclaw:fix/attached-lock
Oct 29, 2026
Merged

awolverp merged 2 commits into
awolverp:mainfrom
greenclaw:fix/attached-lock

Conversation

@greenclaw

Copy link
Copy Markdown
Contributor

Follow-up to #95. That fix made the insert/get/remove paths wait for the cache lock with lock_py_attached, but the other methods still call Wrapped::policy(), a plain lock() that blocks while attached to the interpreter: __len__, __bool__, __sizeof__, capacity, current_size, remaining_size, is_empty, is_full, popitem, shrink_to_fit, clear, __clear__, plus expire / popitem_with_expire on the TTL caches. They deadlock the same way #95 did, and the GIL build is affected too:

import sys, threading
import cachebox

sys.setswitchinterval(1e-6)
cache = cachebox.LRUCache(0)

class Slow:
    def __del__(self):
        for i in range(200):
            pass

stop = threading.Event()

def reader():
    while not stop.is_set():
        len(cache)

t = threading.Thread(target=reader)
t.start()
for _ in range(200):
    for i in range(100):
        cache[i] = Slow()
    cache.clear()
stop.set()
t.join()
print("ok")

On 3.13 this never prints ok: clear() holds the lock while the values are dropped, a __del__ hands the GIL to the reader, and the reader blocks in len() holding the GIL. On free-threaded builds a thread blocked in a plain lock() holds up a stop-the-world (thread start, GC) that the lock holder is waiting for.

Change

  • Every inner.policy() in src/pyclasses/ becomes inner.policy_py_attached(py); the methods that had no py argument get one (PyO3 injects it, the Python signatures and .pyi do not change).
  • Wrapped::remaining_size takes py and waits the same way.
  • Wrapped::policy() is removed, so a new plain-lock call site no longer compiles; the doc comment on inner says why.

No behaviour change when the lock is free: lock_py_attached tries try_lock() first and only detaches on contention. A single-thread micro-benchmark of the changed methods (len, bool, is_full, capacity, current_size, popitem + set; LRU and TTL, release builds, min of 5 interleaved runs) shows differences within ±1 ns in both directions between main and this branch.

Test

test_lock_waiters_with_finalizers runs in a child process like test_concurrent_inserts_with_finalizers: batches of 8 short-lived threads insert values with a __del__ and call the methods above at random.

  • Fork CI with the test alone on main (run): it hangs for all 7 classes on 3.10–3.15 (ubuntu, macos, windows), for 6 of 7 on 3.14t and 3.15t; pypy3.11 does not reproduce.
  • With the fix (run): all jobs green.
  • Locally, main: 21/21 runs hang on 3.13, 29/35 on 3.14t; this branch: 70/70 and 70/70 pass.

Not in this PR: __eq__ / __ne__ lock the two caches in argument order, so a == b and b == a on two threads can still deadlock. I can open a separate issue for that.

@awolverp
awolverp merged commit e675cef into awolverp:main Oct 29, 2026
12 checks passed
@greenclaw
greenclaw deleted the fix/attached-lock branch October 29, 2026 11:08
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.

2 participants