Metadata-Version: 2.4
Name: atomicshm
Version: 0.1.0
Summary: Lock-free atomic operations (CAS, fetch-add, ...) on Python shared memory
Author: Rex Kerr
License-Expression: MIT
Project-URL: Homepage, https://github.com/Ichoran/py-atomic-shared-mem
Project-URL: Issues, https://github.com/Ichoran/py-atomic-shared-mem/issues
Keywords: atomic,shared-memory,cas,compare-and-swap,lock-free,ipc
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: C
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: System :: Distributed Computing
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# atomicshm

Access shared memory in Python with atomic operations, for cases where the other
sharing party requires atomicity.

Python gives you shared memory but no way to touch it atomically. If another
process — C, Rust, Go, C++ — is running a lock-free protocol over those bytes,
`memoryview` assignment is not good enough: it is not atomic, it carries no
memory ordering, and the peer has no idea the GIL exists. This gives you the
handful of operations that are actually needed, and nothing else.

```python
from multiprocessing.shared_memory import SharedMemory
import atomicshm

shm = SharedMemory(create=True, size=4096)
with atomicshm.AtomicView(shm.buf) as view:
    view.store_u64(0, 0)
    previous = view.fetch_add_u64(0, 1)      # returns the value it replaced
    if view.cas_u32(64, 0, 1) == 0:          # compare-and-swap succeeded
        ...
```

- **8, 16, 32, and 64-bit** load, store, exchange, compare-and-swap, and
  fetch-add/sub/and/or/xor.
- **Explicit memory ordering** — relaxed, acquire, release, acq_rel, seq_cst.
- **14–28 ns per operation**, which is less than calling an empty Python
  function.
- **No dependencies**, no runtime configuration, ~1300 lines of C (mostly one
  macro expanded four times).
- One `abi3` wheel per platform, working on CPython 3.11 and every version after.

## Install

```
pip install atomicshm
```

Wheels are published for Linux (manylinux and musllinux, x86-64 and aarch64),
macOS (Intel and Apple silicon), and Windows (x64 and ARM64). Anywhere else, the
sdist builds with any C compiler and no other tooling.

## The operations

Create an `AtomicView` over any writable, C-contiguous buffer — `SharedMemory.buf`,
an `mmap`, a `bytearray`, a numpy array. Then, for each width `N` in
`8`, `16`, `32`, `64`:

| Method | Returns |
| --- | --- |
| `load_uN(offset, order=SEQ_CST)` | the value |
| `store_uN(offset, value, order=SEQ_CST)` | `None` |
| `exchange_uN(offset, value, order=SEQ_CST)` | the **previous** value |
| `cas_uN(offset, expected, desired, order=SEQ_CST, fail_order=…)` | the **previous** value |
| `fetch_add_uN` / `fetch_sub_uN` / `fetch_and_uN` / `fetch_or_uN` / `fetch_xor_uN` `(offset, value, order=SEQ_CST)` | the **previous** value |
| `cell_uN(offset)` | an `AtomicUN` bound to that offset |

Plus `view.nbytes`, `view.address`, `view.closed`, `view.close()`, and
`atomicshm.fence(order)`.

Arithmetic wraps at the width. All arguments are positional-only.

### `cas` returns the previous value, not a bool

That is what the hardware gives you, and it means a retry loop never needs a
reload:

```python
current = view.load_u64(off)
while (previous := view.cas_u64(off, current, current + 1)) != current:
    current = previous
```

The swap succeeded if and only if the return value equals `expected`.

### Cells, for hot fixed offsets

Control fields — a queue head, a lock word — live at a known offset. Binding one
once moves the bounds and alignment checks out of the loop:

```python
counter = view.cell_u64(0)
bump = counter.fetch_add           # hoist the attribute lookup too
for _ in range(1_000_000):
    bump(1)
```

Cells have the same operations without the `offset` argument and without the
width suffix: `load`, `store`, `exchange`, `cas`, `fetch_add`, …. A cell holds a
reference to its view, so the mapping stays alive as long as the cell does.

### Values are unsigned coming out, either way going in

Arguments may be signed or unsigned — anything in `[-2**(N-1), 2**N)` — and are
stored as the low `N` bits. Results are always unsigned, because that is the one
interpretation that is always well defined. Wrapping arithmetic is identical for
both, so a signed counter only needs converting when you look at it:

```python
atomicshm.as_signed(view.load_u32(0), 32)     # 0xFFFFFFFF -> -1
```

### Memory ordering

`SEQ_CST` by default: correct for any protocol, and the right thing to reach for
unless you have a specific reason not to. Weaker orders are available where they
matter:

| Operation | Accepts |
| --- | --- |
| `load` | `RELAXED`, `ACQUIRE`, `SEQ_CST` |
| `store` | `RELAXED`, `RELEASE`, `SEQ_CST` |
| everything else | all five |

Passing an order an operation cannot use — `RELEASE` to a load, say — raises
`ValueError` rather than being silently reinterpreted. `cas` takes a second,
optional failure order (`RELAXED`, `ACQUIRE`, or `SEQ_CST`), defaulting to the
success order with its release half removed.

On MSVC every operation carries a full barrier regardless of the order you ask
for. Stronger than requested is always sound, and it keeps the x64/ARM64
differences out of the code entirely — see [DESIGN.md](DESIGN.md).

## Two things that will bite you

### Alignment is required

An access at `offset` must be `N/8`-byte aligned. x86 would tolerate a misaligned
atomic; AArch64 will fault or silently stop being atomic. So misalignment raises
`ValueError` on every platform, including the ones that would have let you get
away with it locally. `SharedMemory` and `mmap` are page-aligned, so in practice
this only constrains how you lay out your struct.

### The view holds the buffer exported

An `AtomicView` keeps its target's buffer exported for its whole life, so the
mapping cannot be unmapped out from under it. The flip side is that
`SharedMemory.close()` raises `BufferError` until the view lets go:

```python
view = atomicshm.AtomicView(shm.buf)
...
view.close()        # or use the view as a context manager
shm.close()
shm.unlink()
```

This is also what makes it safe to resolve an address once and reuse it. A view
resolves its base pointer at construction and a cell resolves its slot address
at creation; neither is re-derived per call. Three rules keep that sound:

- **The export pins the memory.** While a buffer is exported, its owner may not
  move or free it — `bytearray.append`, `mmap.close`, and `memoryview.release`
  all raise `BufferError` until the view lets go.
- **The owner cannot be collected.** A view holds a strong reference to its
  target and a cell holds one to its view, so nothing a cell depends on can be
  deallocated while the cell is alive — including under the cycle collector,
  which can only reclaim a view when the cells pointing at it are unreachable too.
- **A view's base is write-once.** `close()` can clear it, nothing can re-point
  it, and `AtomicView.__init__` refuses a second call. So while a view is open,
  its base is the value every cell resolved against.

An address is therefore never used without first confirming, through an owned
reference, that the export is still live. The failure mode when a view is closed
is a `ValueError`, never a stale pointer.

## Talking to a peer process

Lay the peer's struct out with natural alignment and address the fields by
offset. Given:

```c
struct control {                        // C
    _Atomic uint64_t head;              // offset 0
    _Atomic uint64_t tail;              // offset 8
    _Atomic uint32_t lock;              // offset 16
};
```

```rust
#[repr(C)]                              // Rust
struct Control {
    head: AtomicU64,                    // offset 0
    tail: AtomicU64,                    // offset 8
    lock: AtomicU32,                    // offset 16
}
```

```python
head = view.cell_u64(0)                 # Python
tail = view.cell_u64(8)
lock = view.cell_u32(16)
```

The orders map one-to-one onto C11's `memory_order_*` and Rust's
`Ordering::*`, and the operations onto `atomic_fetch_add` /
`fetch_add`, `atomic_compare_exchange_strong` / `compare_exchange`, and so on.
`cas` is always the strong form; there is no weak variant, because spurious
failure has no upside from Python.

### A spinlock

```python
lock = view.cell_u32(16)

def acquire():
    while lock.cas(0, 1, atomicshm.ACQUIRE) != 0:
        pass

def release():
    lock.store(0, atomicshm.RELEASE)
```

### A single-producer ring buffer index

```python
head, tail = view.cell_u64(0), view.cell_u64(8)

def push(payload):                                   # producer side
    t = tail.load(atomicshm.RELAXED)                 # only we write it
    if t - head.load(atomicshm.ACQUIRE) == CAPACITY:
        return False
    buf[slot(t)] = payload                           # publish the payload...
    tail.store(t + 1, atomicshm.RELEASE)             # ...then the index
    return True
```

The release store is what makes the payload visible to the consumer before the
index that points at it.

## Performance

On a Linux x86-64 desktop, CPython 3.12 (`python benchmarks/bench.py`):

```
empty python function call            38.3 ns     <- the floor for any Python call
AtomicView.load_u64                   18.5 ns
AtomicView.fetch_add_u64              27.7 ns
AtomicView.cas_u64                    24.2 ns
AtomicU64.load                        14.0 ns
AtomicU64.fetch_add                   24.5 ns
AtomicU64.cas                         22.4 ns
```

Every operation costs less than calling an empty Python function, and most of
what is left is vectorcall dispatch rather than the atomic instruction — a
`lock cmpxchg` is a handful of nanoseconds. There is no `PyArg_ParseTuple`, no
tuple allocated for arguments, no Python frame pushed, and the GIL is never
released.

In throughput terms that is roughly 35–70 million operations per second from a
single Python thread, loop overhead included. The practical consequence is that
you can stop designing around the cost: a shared counter, a lock word, or a ring
buffer index can be touched on the hot path without the access itself becoming
the thing you have to justify.

## What this is not

No mutexes, condition variables, or any blocking primitive; no `futex` /
`WaitOnAddress` parking; no 128-bit atomics; no weak compare-exchange; no
`fetch_min`/`fetch_max`; no atomic access to Python objects. The scope is the
complete set of operations needed to implement a lock-free protocol against a
peer process, and stopping there is what lets the library be finished.

If you need a lock, build it out of `cas` — the recipe above is the whole thing.

## Compared with `atomics`

[`atomics`](https://github.com/doodspav/atomics) solves the same problem and
covers more ground: it wraps the [patomic](https://github.com/doodspav/patomic)
C library through CFFI, supports Python 3.6+, and offers a richer type model
(`AtomicInt`, `AtomicUint`, `AtomicBytes`, alignment introspection). It is good
software, and if you need Python 3.6–3.10 it is your option.

The difference that will decide it for most projects is cost at the call site.
Same 64-bit slot, same mapping, Linux x86-64, CPython 3.12:

| | `atomics` 1.0.3 | `atomicshm` | |
| --- | --- | --- | --- |
| `load` | ~2.7 µs | ~8 ns | ~340× |
| `store` | ~2.8 µs | ~10 ns | ~280× |
| `fetch_add` | ~4.6 µs | ~15 ns | ~310× |
| compare-and-swap | ~5.9 µs | ~17 ns | ~340× |

A gap that size deserves an explanation rather than a benchmark table, and it
has a simple one. **`atomics` makes 65 Python function calls per `load`, 114 per
`fetch_add`, and 142 per compare-and-swap. This library makes none.** Profiling
one `load` shows `_released` twelve times, `_assert_not_released` eight times,
`address` three times, `release` three times, plus `cffi.cast` and
`from_buffer` — every call re-acquires the buffer, re-derives the pointer
through CFFI, and re-validates. Multiply those call counts by the ~38 ns an
empty Python call costs and you predict 2.5 / 4.4 / 5.4 µs against 2.8 / 4.6 /
5.9 µs measured. The whole gap is Python call overhead. Neither library is
spending meaningful time on the atomic instruction.

`atomicshm` resolves the address **once**, at `AtomicView` construction and
again at `cell_uN()`, and then does nothing per call but a bounds check, an
alignment check, and the instruction. It can cache that pointer safely for
exactly the reason described in [the export note above](#the-view-holds-the-buffer-exported):
holding the buffer exported is what guarantees the mapping cannot move or
vanish. The safety property and the speed are the same mechanism.

This matters because latency is usually the entire reason to reach for shared
memory. At microseconds per operation, atomic access is something you budget
for, ration, and design around — batching to amortise it, or accepting it and
justifying the cost. At tens of nanoseconds it stops being a design constraint.
A million compare-and-swaps is ~17 ms rather than ~6 seconds.

The second difference is the license: **`atomics` is GPL-3.0, this is MIT.**
These are single hardware instructions with an argument check in front of them,
and a copyleft obligation across an entire application is a steep price for
`lock xadd`. Worth noting that patomic itself is LGPL-3.0-or-later *with a
linking exception* — the copyleft is a choice made at the Python wrapper layer,
not inherited from the atomics implementation. `atomicshm` also has no runtime
dependency at all, where `atomics` requires `cffi`.

The two do interoperate: both perform genuine hardware atomics on the same
bytes, so a process using one and a process using the other coordinate
correctly.

Measured with `timeit`, best of 5 runs of 20,000 iterations, bound methods
hoisted out of the loop for both; call counts from `cProfile`. No comparison
script ships here — running one would mean importing a GPL library from an MIT
package, which is one of the things this library exists to avoid.

### Provenance

`atomicshm` was written from the GCC/Clang and MSVC intrinsic documentation and
the CPython C API. No source from `atomics` or `patomic` was read, copied, or
adapted, and the API here was designed and fully implemented before either was
known to be relevant.

## Requirements

CPython 3.11+ on a 64-bit platform. Free-threaded builds are supported and do
not re-enable the GIL. The build fails rather than produce a binary whose
"atomics" are a process-private lock, which would be silently useless across
processes.

## License

MIT
