Skip to content

AtomicPy experiment#5356

Draft
Icxolu wants to merge 1 commit intoPyO3:mainfrom
Icxolu:atomicpy
Draft

AtomicPy experiment#5356
Icxolu wants to merge 1 commit intoPyO3:mainfrom
Icxolu:atomicpy

Conversation

@Icxolu
Copy link
Member

@Icxolu Icxolu commented Aug 22, 2025

This is an experiment playing with the idea (#5349 (comment)) of an AtomicPy that can be swapped locked-free using an AtomicPtr internally. This is basically a really thin layer on top of AtomicPtr, just to enough to handle the reference counting, so it's still fairly low level. There could also be a companion AtomicOptionPy that makes use of the null-pointer.

The primary use case I could imagine for this are frozen pyclasses (in combination with the free-threaded build). There may be others as well. I would be curious what others think about this.

Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

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

Thanks for sketching this out! I had a first read, mostly looks like how I expect. I think I spotted an edge case which makes me wonder if this is going to end up a bit hamstrung, need to think about that 🤔

The interaction between AtomicPy / AtomicOptionPy is also a touch unfortunate, but I guess Option<AtomicPy<T>> is also unhelpful for obvious reasons. I guess AtomicPtr is technically nullable, should we just lean into that and only have the AtomicOptionPy form?

src/atomic.rs Outdated
Comment on lines +123 to +126
// SAFETY: `ptr` is a non-null, borrowed pointer to `ffi::PyObject` of type `T`
Err(Borrowed::from_ptr_unchecked(py, ptr)
.to_owned()
.cast_into_unchecked())
Copy link
Member

Choose a reason for hiding this comment

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

I wonder, is there a potential race here if another thread writes avalue to the AtomicPy dropping this ptr before the .to_owned call?

... is this potentially indicative of a more general problem with AtomicPy, e.g. does load have the same issue? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

Uff, I think you're right here. I guess this means we can only really provide the swap operation (and probably take for the option variant), because that does not touch the ref-count (at least if we want to keep this a thin wrapper).

I could image providing load and swap if we take more control.

  • load would need to swap with nullptr, increment the ref-count, store back original
  • load and swap spin briefly if we're in the middle of a load
  • we can't expose an Ordering anymore

Not sure if that would have significant benefits left over a Mutex<Py>.

compare_exchange looks pretty hopeless, I don't see a way to expose that.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think it's possible to expose this in general, you end up needing something like crossbeam-epoch or arc-swap.

/// store part of this operation [Relaxed](Ordering::Relaxed), and using
/// [Release](Ordering::Release) makes the load part [Relaxed](Ordering::Relaxed).
#[inline]
pub fn swap<'py>(&self, obj: Bound<'py, T>, order: Ordering) -> Bound<'py, T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

It confused me a bit confusing to have from_bound and from_py on one side and swap_unbound and swap on the other side. What about something like from_bound/from_unbound and swap_bound/swap_unbound?

@davidhewitt
Copy link
Member

Revisiting this, I wonder if PyUnstable_TryIncRef (added in Python 3.14) could enable a best-in-class load implementation (would require load to take py: Python as an argument, not sure how it would affect swap).

@Icxolu
Copy link
Member Author

Icxolu commented Feb 26, 2026

I think that could work to implement load, yes, but it would need to return an Option<Py<T>> I guess to handle the case that we loaded the pointer but someone else swapped and dropped it before we could inc-ref it and thus PyUnstable_TryIncRef fails. We would also need to enable this using PyUnstable_EnableTryIncRef I think, so the constructor probably also needs a Python<'py> token. I don't think swap will be affected, because that does not touch any refcounts, it takes an owned value and returns also an owned value by a single atomic pointer swap.

(But it's been a while, so there might be thinks that I haven't thought about 😄)

@Icxolu
Copy link
Member Author

Icxolu commented Feb 27, 2026

Thinking more about this, this might not be possible. Presumably PyUnstable_EnableTryIncRef requires that the PyObject allocation is still be valid (to check whether the refcount is zero), I don't think we can guarantee this here.
I could imagine the following scenario:

  • thread A loads the pointer
  • then thread B swaps out the value and drops it
  • GC runs, sees that the refcount is 0 and frees the PyObject allocation
  • thread A continues and calls PyUnstable_EnableTryIncRef with a now dangling pointer which is most likely UB

I guess on gil-enabled build this is not possible, because load holds a Python token, but on the free-threaded build I think this could happen.

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.

4 participants