Skip to content

Avoid GC and value-finalizer deadlocks while the cache lock is held - #85

Draft
Malkiz223 wants to merge 1 commit into
awolverp:mainfrom
Malkiz223:traverse_no_wait
Draft

Avoid GC and value-finalizer deadlocks while the cache lock is held#85
Malkiz223 wants to merge 1 commit into
awolverp:mainfrom
Malkiz223:traverse_no_wait

Conversation

@Malkiz223

@Malkiz223 Malkiz223 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Reference for #84.

Four changes:

  • __traverse__ tries the lock and skips the pass when it is busy, instead of deadlocking against a thread that runs Python under the lock. Skipping is safe: the cache and its contents just stay reachable until the next pass, and a busy lock means some thread is inside a cache operation and holds a reference to the cache anyway.
  • Values removed by eviction, replacement, drain(), clear(), the expiry sweep and their error paths are parked in a pending_drops buffer on the policy, and the lock guard first releases the lock and only then drops them - so a value's __del__ never runs under the lock.
  • update() transforms up to 1024 items with no lock held and takes the lock once per batch, for the inserts alone; the batch deque doubles as the deferred-drop buffer. Two behavior notes: on an error, whatever was already inserted stays - the finished batches plus, when the error strikes during the inserts themselves, the failing batch's prefix - and the buffered rest is discarded. And another thread can observe the cache between batches.
  • setdefault() and setdefault_with() build the new item - and call getsizeof - with the lock released, the way Call the setdefault_with factory without holding the lock #68 already runs the factory. If another thread inserts the key meanwhile, its value wins and the prepared item is parked; a losing getsizeof or factory still runs first, and its exception, if any, propagates (documented in the docstrings).

Found but not fixed here: __eq__ still runs under the lock - a key's during a probe, values' when comparing two caches - and so does the repr() of items while the cache is being formatted. The __traverse__ change makes them safe from the collector; a reentrant call into the same cache from there still deadlocks, as does comparing two caches from two threads in opposite orders.

Cost on my machine, both wheels built with the same toolchain - what pays is moving destruction out of the lock:

main this PR
lookups, inserts, single evictions unchanged
update(), 64 items from a dict 1.9 us 2.4 us
update(), 64 items from a generator 3.9 us 4.6 us
clear(), 1000 entries 12.9 us 15.3 us
drain(), 1000 entries 16.9 us 22.2 us
update(), 2M pairs into LRUCache(1000) 133 ms 155 ms
setdefault(), hit / miss 38 / 80 ns 38 / 93 ns

The regression tests follow the shape of the setdefault_with one from #68 - a child process with a timeout, failing with "never returned" on main instead of freezing the run. They cover, for all seven cache types: the deadlock scripts from the issue, a __del__ that reaches into its own cache from every removal path, the update() error edges - a rejected item, a failing getsizeof, a malformed item, an error after a full batch - with the finalizers of the rejected and unprocessed items observed, and a getsizeof that touches the cache from setdefault() and setdefault_with().

This is a large diff, so please take your time with it. Most of the line count is the same change stamped across the seven cache types, plus the tests. If you would rather review it in smaller pieces, say so and I will split it.

@chirizxc

Copy link
Copy Markdown
Contributor

It seems to me that the descriptions for PR and issue from LLM are rather wordy

Comment on lines +124 to +127
/// Handles parked for destruction after the lock is released;
/// see [`super::traits::PolicyExt::pending_drops`].
pending_drops: Vec<Handle>,

@chirizxc chirizxc Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be:

pending_drops: smallvec::SmallVec<[Handle; 1]>  // or [Handle; 2]

Changing it to smallvec::SmallVec<[Handle; 1]> (or [Handle; 2]) would eliminate the allocation for single evictions / insert-replaces the most common scenario (insert, setdefault, remove) and would degrade to Vec-like behavior only during clear() or drain(n) with a large n

What do you think about this? @awolverp

Comment thread src/policies/nopolicy.rs
Comment on lines +92 to +94
/// Handles parked for destruction after the lock is released;
/// see [`super::traits::PolicyExt::pending_drops`].
pending_drops: Vec<Handle>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/policies/nopolicy.rs
Self {
table: hashbrown::raw::RawTable::with_capacity(capacity),
currsize: 0,
pending_drops: Vec::new(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/policies/nopolicy.rs
Comment on lines +183 to +186
#[inline(always)]
fn pending_drops(&mut self) -> &mut Vec<Handle> {
&mut self.pending_drops
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/policies/wrapped.rs
return;
}

let pending = std::mem::take(buffer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let pending = std::mem::take(buffer);
let pending: SmallVec<[P::Handle; 1]> = std::mem::take(buffer);

Comment thread src/policies/traits.rs
/// the same cache. Internal operations that remove handles park them here
/// instead of dropping them; [`super::wrapped::PolicyGuard`] empties the
/// buffer right after it releases the lock.
fn pending_drops(&mut self) -> &mut Vec<Self::Handle>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn pending_drops(&mut self) -> &mut Vec<Self::Handle>;
fn pending_drops(&mut self) -> &mut SmallVec<[Self::Handle; 1]>;

Comment thread src/policies/ttlpolicy.rs
table: hashbrown::raw::RawTable::with_capacity(capacity),
entries: VecDeque::with_capacity(capacity),
currsize: 0,
pending_drops: Vec::new(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pending_drops: Vec::new(),
pending_drops: SmallVec::new(),

Comment thread src/policies/nopolicy.rs

/// Handles parked for destruction after the lock is released;
/// see [`super::traits::PolicyExt::pending_drops`].
pending_drops: Vec<Handle>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pending_drops: Vec<Handle>,
pending_drops: SmallVec<[Handle; 1]>,

Comment thread src/pyclasses/cache.rs
Comment on lines +361 to 377

let mut policy = inner.policy();

let existing = policy
.get(py, handle.key(), inner.shared())?
.map(|x| x.value().clone_ref(py));
if let Some(existing) = existing {
// Lost the race: the winner's value is returned, ours is parked.
policy.pending_drops().push(handle);
return Ok(existing);
}

if let Some(old) = inner.insert_no_lock(&mut policy, py, handle)? {
// Only reachable when the key's __eq__ is inconsistent.
policy.pending_drops().push(old);
}
Ok(default_object)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let mut policy = inner.policy();
let existing = policy
.get(py, handle.key(), inner.shared())?
.map(|x| x.value().clone_ref(py));
if let Some(existing) = existing {
// Lost the race: the winner's value is returned, ours is parked.
policy.pending_drops().push(handle);
return Ok(existing);
}
if let Some(old) = inner.insert_no_lock(&mut policy, py, handle)? {
// Only reachable when the key's __eq__ is inconsistent.
policy.pending_drops().push(old);
}
Ok(default_object)
match policy.entry(py, handle.key(), inner.shared())? {
PolicyEntry::Occupied(occupied) => {
// Lost the race: the winner's value is returned, ours is parked.
let existing = occupied.get().value().clone_ref(py);
policy.pending_drops().push(handle);
Ok(existing)
}
PolicyEntry::Vacant(vacant) => {
while vacant.would_exceed(handle.size()) {
vacant.evict()?;
}
vacant.insert(handle);
Ok(default_object)
}
}

@awolverp

awolverp commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Thank you. There are quite a lot of changes, I need to take some time to review them.

It would have been better if you had created a separate PR for each fix.

I agree with the __traverse__ fix and it’s correct.
But I’m not a fan of the pending_drops solution and I need to thoroughly review it. It makes the code overly complicated.
I should try to find an alternative solution. if I can’t find one, I’ll come back to this solution.

@Malkiz223

Copy link
Copy Markdown
Contributor Author

Thanks for the review - agreed, this should have been split from the start. The __traverse__ part is now #86, it closes every deadlock from #84 on its own.

On pending_drops - I'd be glad if you find a simpler shape yourself. I'll keep this PR as a reference rather than something to merge. And if any of it does get reused, @chirizxc's SmallVec suggestion fits it well.

@Malkiz223
Malkiz223 marked this pull request as draft August 30, 2026 14:13
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.

3 participants