Avoid GC and value-finalizer deadlocks while the cache lock is held - #85
Avoid GC and value-finalizer deadlocks while the cache lock is held#85Malkiz223 wants to merge 1 commit into
Conversation
|
It seems to me that the descriptions for PR and issue from LLM are rather wordy |
| /// Handles parked for destruction after the lock is released; | ||
| /// see [`super::traits::PolicyExt::pending_drops`]. | ||
| pending_drops: Vec<Handle>, | ||
|
|
There was a problem hiding this comment.
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
| /// Handles parked for destruction after the lock is released; | ||
| /// see [`super::traits::PolicyExt::pending_drops`]. | ||
| pending_drops: Vec<Handle>, |
| Self { | ||
| table: hashbrown::raw::RawTable::with_capacity(capacity), | ||
| currsize: 0, | ||
| pending_drops: Vec::new(), |
| #[inline(always)] | ||
| fn pending_drops(&mut self) -> &mut Vec<Handle> { | ||
| &mut self.pending_drops | ||
| } |
| return; | ||
| } | ||
|
|
||
| let pending = std::mem::take(buffer); |
There was a problem hiding this comment.
| let pending = std::mem::take(buffer); | |
| let pending: SmallVec<[P::Handle; 1]> = std::mem::take(buffer); |
| /// 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>; |
There was a problem hiding this comment.
| fn pending_drops(&mut self) -> &mut Vec<Self::Handle>; | |
| fn pending_drops(&mut self) -> &mut SmallVec<[Self::Handle; 1]>; |
| table: hashbrown::raw::RawTable::with_capacity(capacity), | ||
| entries: VecDeque::with_capacity(capacity), | ||
| currsize: 0, | ||
| pending_drops: Vec::new(), |
There was a problem hiding this comment.
| pending_drops: Vec::new(), | |
| pending_drops: SmallVec::new(), |
|
|
||
| /// Handles parked for destruction after the lock is released; | ||
| /// see [`super::traits::PolicyExt::pending_drops`]. | ||
| pending_drops: Vec<Handle>, |
There was a problem hiding this comment.
| pending_drops: Vec<Handle>, | |
| pending_drops: SmallVec<[Handle; 1]>, |
|
|
||
| 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) |
There was a problem hiding this comment.
| 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) | |
| } | |
| } |
|
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 |
|
Thanks for the review - agreed, this should have been split from the start. The On |
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.drain(),clear(), the expiry sweep and their error paths are parked in apending_dropsbuffer 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()andsetdefault_with()build the new item - and callgetsizeof- 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 losinggetsizeoforfactorystill 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 therepr()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:
mainupdate(), 64 items from a dictupdate(), 64 items from a generatorclear(), 1000 entriesdrain(), 1000 entriesupdate(), 2M pairs intoLRUCache(1000)setdefault(), hit / missThe regression tests follow the shape of the
setdefault_withone from #68 - a child process with a timeout, failing with "never returned" onmaininstead 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, theupdate()error edges - a rejected item, a failinggetsizeof, a malformed item, an error after a full batch - with the finalizers of the rejected and unprocessed items observed, and agetsizeofthat touches the cache fromsetdefault()andsetdefault_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.