Skip to content

About

POSIX IPC and synchronization primitives for Python

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

posixipc

PyPI version Python 3.12+ License: MIT

POSIX IPC and synchronization primitives for CPython: robust mutexes with on_owner_died, typed Layout segments, in-memory queues, and POSIX named semaphores and message queues. Linux extras (futex, eventfd, memfd, FutexQueue) live in posixipc.linux.

Warning

This code was written with Cursor (AI, multi-agent). It has not had a human review. I will review it later, then publish packages.

See docs/guide.md for types and usage, docs/queue.md for queue layouts, docs/examples/ for samples, docs/design.md for recovery and trust notes, and CHANGELOG.md for releases.

Contents


Install

pip install python-posixipc

Requires CPython 3.12+ on Linux. Build from source needs a C11 compiler, CMake ≥ 3.20, and CPython development headers.


Quick start

Do not create() and attach() in the same process. Put the layout and on_owner_died in a module both processes import.

import posixipc

NAME = "/myapp.state"


class App:
    def __init__(self):
        self.layout = posixipc.Layout()
        self.mutex = self.layout.add(
            posixipc.RobustMutex,
            on_owner_died=self.recover,
        )
        self.cond = self.layout.add(posixipc.Condition, mutex=self.mutex)
        self.state = self.layout.add_bytes(4096)

    def recover(self, mutex):
        # Previous owner died. You hold the lock. Repair self.state.
        # Return → consistent, acquire succeeds.
        # Raise → unlock (poison), exception propagates.
        ...


app = App()
app.layout.create(NAME)
with app.mutex:
    while not ready():
        app.cond.wait()
    use(app.state)

# other process
app = App()
app.layout.attach(NAME)
with app.mutex:
    use(app.state)

ok = app.mutex.acquire(timeout=1.0)  # False on timeout
if ok:
    try:
        use(app.state)
    finally:
        app.mutex.release()

add() returns unbound handles. create / attach / open_or_create bind them and seal the layout; further add() raises RuntimeError.

Process-private lock, no layout:

m = posixipc.Mutex()
with m:
    ...

When to use

Need Use
Threads in one process threading.Lock
Processes, holder will not crash posixipc.Mutex or multiprocessing.Lock
Processes, holder may crash RobustMutex + on_owner_died
Primitives in your own shared-memory layout posixipc

Use threading.Lock for threads; that is the in-process API. Uncontended posixipc.Mutex is faster than threading.Lock on this machine, and about the same as multiprocessing.Lock. See Performance.


API

RobustMutex requires on_owner_died(mutex). Rejected at add() / construction without it. No acquire_recoverable(), LockState, or OwnerDiedError.

from posixipc import (
    SharedMemory,
    Layout,
    Mutex,
    RobustMutex,
    RWLock,
    Condition,
    Semaphore,
    Queue,
    features,
    PosixIPCError,
    NotRecoverableError,
    ClosedError,
    LayoutMismatchError,
    TimeoutError,
)

Barrier, NamedSemaphore, and NamedMessageQueue are in the same namespace only when detected. Otherwise from posixipc import Barrier is ImportError. Check features.

SpinLock is imported from posixipc.spinlock (or posixipc._posixipc) when features.spinlock is set. acquire(timeout=…) is ValueError.

MutexArray comes from layout.add_array. arr[i] is a MutexArrayItem (same object type on_owner_died receives). layout.add_bytes returns SharedBytes (buffer protocol into that slot).

__build_info__ is the raw C probe dict. Prefer features.

Linux-only types live in posixipc.linux: Futex, EventFD, MemFD, memfd, FutexQueue, and linux.features (futex, eventfd, memfd, futex_queue).

SharedMemory.create / attach_unchecked / open_or_create are untyped regions (digest 0). Application code should use Layout.


Design

Object model

The Python object is a handle. The primitive lives in storage the handle points at. A process-shared pthread_mutex_t must sit in mmap(MAP_SHARED); a PyObject sits on the interpreter heap. Shared handles therefore point into the mapping. Private handles keep the primitive inline.

typedef struct {
    PyObject_HEAD
    pthread_mutex_t   *lock;     /* shm slot, or inline_storage */
    PyObject          *region;   /* SharedMemory, or NULL if private */
    uint32_t           slot;
    _Atomic uint32_t   flags;
    pthread_mutex_t    inline_storage;
    PyObject          *on_owner_died;
} PosixIPCMutexObject;

OWNS_STORAGE is set only for private inline storage. Shared handles never own the pthread object. region keeps the Python object alive; a pin keeps the mapping alive. flags is _Atomic on every build.

Shared memory

Implemented with shm_open / ftruncate / mmap / munmap / shm_unlink, not multiprocessing.shared_memory.

Typed segments start with a 64-byte header:

typedef struct {
    uint32_t magic;              /* 0x50495043u ('PIPC') */
    uint16_t layout_version;
    uint16_t slot_count;
    uint32_t abi_tag;
    uint32_t flags;
    uint32_t total_size;
    uint32_t directory_bytes;
    _Atomic uint32_t state;      /* UNINIT=0 | INITIALIZING | READY | BROKEN */
    uint32_t layout_digest;
    uint32_t reserved[8];
} posixipc_shm_header;

_Static_assert(sizeof(posixipc_shm_header) == 64, "header must be one cache line");
_Static_assert(ATOMIC_INT_LOCK_FREE == 2, "header state must be lock-free");

total_size and slot offset are uint32_t. Segments larger than 4 GiB − 1 raise OverflowError at create(). Size checks are size <= total_size - offset.

UNINIT is 0 (ftruncate zero-fills). open_or_create CASes INITIALIZING. If the creator fails after the claim, BROKEN is stored with release ordering and attachers raise.

Slot directory after the header, one record per add():

typedef struct {
    uint16_t kind;
    uint16_t align;
    uint32_t offset;
    uint32_t size;
    uint32_t init_flags;
} posixipc_slot;

Mutex and RobustMutex have different kind values. abi_tag includes arch, libc family, sizeof/alignof, and cache-line stride. glibc and musl pthread_mutex_t are both 40 bytes on x86-64 and are not interchangeable.

Layout.create():

  1. shm_open(O_CREAT|O_EXCL|O_RDWR, 0600), fstat st_uid == geteuid() and (st_mode & 077) == 0.
  2. ftruncate to header + directory + slots.
  3. mmap, write header (except state), write directory, *_init every slot.
  4. Store READY with release ordering.
  5. Bind handles.

attach():

  1. shm_open existing, same fstat.
  2. Wait until st_size >= 64, map the header, wait for READY or BROKEN.
  3. Check total_size against st_size and the caller's expected size before mapping the rest.
  4. Verify magic, versions, abi_tag, digest, directory.
  5. Bind handles. Never *_init.

open_or_create() is create, then attach on EEXIST, with a bounded retry if the name disappears. If the creator dies after O_EXCL and before READY, attachers time out (TimeoutError) unless a supervisor unlinks and recreates.

close() unmaps when the pin count is zero. unlink() removes the name.

Layout and digest

layout = posixipc.Layout()
mutex = layout.add(posixipc.RobustMutex, on_owner_died=recover, prio_inherit=False)
cond = layout.add(posixipc.Condition, mutex=mutex)
sem = layout.add(posixipc.Semaphore, value=1)
bar = layout.add(posixipc.Barrier, parties=4)  # if features.barrier
blob = layout.add_bytes(4096)
locks = layout.add_array(posixipc.Mutex, 32)
q = layout.add(posixipc.Queue, depth=32, item_size=256)

region = layout.create("/myapp.state")
region = layout.attach("/myapp.state", timeout=5.0)
region = layout.open_or_create("/myapp.state", timeout=5.0)

Omitting timeout on attach / open_or_create is 5 seconds. timeout=None waits forever. On acquire(), omitted timeout waits forever.

Offsets come from the slot sequence and the build ABI. The same add() order in two processes produces the same directory.

layout.digest is FNV-1a 32 in C (posixipc_layout_digest), not hash(). Encoding:

  • Basis 0x811C9DC5, prime 0x01000193.
  • uint16 layout_version LE, uint32 abi_tag LE, uint32 POSIXIPC_CACHELINE_BYTES LE.
  • Per slot, add-order: uint16 kind, uint16 align, uint32 size, uint32 init_flags, all LE.
  • add(X); add(X) is two slots. add_array(X, 2) is the same encoding.
  • Digest 0 is stored as 1. Header digest 0 means untyped (no layout).

The digest catches mismatched layouts. It is not a tamper check. Anyone who can write the segment can forge it.

SharedMemory.create(name, size) writes digest 0 and no directory. There is no SharedMemory.attach(); the unchecked path is attach_unchecked().

Robust mutex

pthread_mutexattr_setrobust(..., PTHREAD_MUTEX_ROBUST).

On EOWNERDEAD you hold the lock. On ENOTRECOVERABLE you do not.

on_owner_died is required on RobustMutex.

  • Called as recover(mutex) from the next acquire() / with / Condition.wait() that sees EOWNERDEAD.
  • A zero-argument callable fails at recover time (TypeError).
  • Handles are already bound. Recovery never runs before create/attach.
  • Return: pthread_mutex_consistent, then acquire succeeds.
  • Raise: pthread_mutex_unlock (poisons the mutex), exception propagates. Later acquires raise NotRecoverableError.
  • Not pickled. Rebuild the layout with the same function, or assign mutex.on_owner_died = recover before the first acquire.

There is no Python consistent(). Other extensions use the capsule in posixipc.h.

Linux delivers EOWNERDEAD on SIGKILL and _exit via robust_list. Not delivered if the mapping is gone before exit, or across panic / power loss. close() / unmap while this process holds a robust lock is unsupported; the pin count makes close() fail instead.

Timeouts and clocks

On acquire:

timeout Meaning
omitted / None block, interruptible
-1 or negative / NaN ValueError
0 same as blocking=False
> 0 absolute deadline, computed once, on that primitive's clock
blocking=False and timeout>0 ValueError

Acquire timeout returns False. Attach / open timeout raises TimeoutError. Condition.wait(blocking=False) is ValueError.

Clocks are per primitive:

  • Condition: pthread_condattr_setclock(CLOCK_MONOTONIC) at init.
  • Mutex / RWLock / Semaphore: *_clocklock / sem_clockwait with CLOCK_MONOTONIC if dlsym finds them at import. musl does not; those primitives use CLOCK_REALTIME and features.monotonic_timeouts["mutex"] is False.
  • Priority inheritance + monotonic needs FUTEX_LOCK_PI2 (Linux ≥ 5.14 and a glibc that uses it). EINVAL from clocklock(MONOTONIC) falls back to realtime and is reported on features.

features flags: robust_mutex, prio_inherit, process_shared, barrier, spinlock, named_semaphore, cond_monotonic, monotonic_timeouts (mutex / rwlock / semaphore / condition → bool), memfd, mq, named_message_queue (same bit as mq), queue (always True).

Deadlines are nanosecond-resolution, limited by the scheduler.

Signals

Blocking acquires that have a timed POSIX variant wait in 50 ms slices and call PyErr_CheckSignals() between slices. Handlers run on the main thread only. interruptible=False opts out (main thread).

If PyErr_CheckSignals() returns < 0, the exception is already set; the wait helper propagates it.

Ctrl-C latency is a slice plus up to sys.getswitchinterval() (5 ms default) when reacquiring the GIL.

Barrier.wait() has no timed POSIX variant and is not interruptible. SpinLock is trylock + yield with the GIL released, and checks signals between yields.

sem_wait returns EINTR even with SA_RESTART. Semaphores use the same wait helper.

GIL and free-threaded CPython

Free-threaded CPython (3.13t / 3.14t) is supported. tox -e py313t,py314t and the linux-glibc CI matrix cover those builds. Import sets Py_mod_gil = Py_MOD_GIL_NOT_USED so free-threaded CPython does not turn the GIL back on.

One Mutex handle may be shared by many threads in the same process. __exit__ always calls release(); pthread ERRORCHECK returns EPERM if this thread does not own the mutex. Condition.wait() uses the same rule (pthread_cond_wait → EPERM). A per-handle locked bit is not the owner.

acquire() calls pthread_mutex_trylock first. On EBUSY it waits with the GIL released (or, without a GIL, just waits). EOWNERDEAD from trylock runs on_owner_died on the calling thread. Blocking paths do not touch a PyObject while waiting.

flags and pin counts are _Atomic on every build. Closing a SharedMemory while views or handles still pin it is BufferError. Closing a handle another thread is still using is a caller bug; the result is ClosedError or a failed close(), not use-after-munmap.

Errors

pthread_* returns errno and does not set errno. sem_*, shm_open, mmap, ftruncate set errno. The C core returns a positive errno. posixipc_raise does errno = rc then PyErr_SetFromErrno. Codes >= 5000 do not go through that path.

Condition Result
success normal return
ETIMEDOUT / EBUSY on timed or non-blocking acquire False
attach / open deadline TimeoutError
EOWNERDEAD on_owner_died(mutex), then success
ENOTRECOVERABLE NotRecoverableError
bad argument ValueError
header / ABI / digest / slot / BROKEN LayoutMismatchError
use after close(), or close() with pins ClosedError or BufferError
double release(), unlock you do not own RuntimeError
other errno OSError
class PosixIPCError(Exception): ...

class NotRecoverableError(PosixIPCError):
    """Permanently unusable. You do not hold the lock."""

class ClosedError(PosixIPCError, ValueError): ...

class LayoutMismatchError(PosixIPCError, ValueError): ...

# TimeoutError is the builtin.

Default mutex type is PTHREAD_MUTEX_ERRORCHECK.

Lifetime

  1. Python wrapper — ends at deallocation.
  2. Mapping — munmap when pins == 0.
  3. Name — shm_unlink / sem_unlink.

Bound handles, memoryviews, and capsules increment the region pin. close() with pins > 0 raises BufferError. tp_dealloc of a shared handle drops a pin; it does not pthread_mutex_destroy shared storage.

Private primitives: destroy is unlock-if-held, then pthread_mutex_destroy. Shared slots are not destroyed. Teardown is unlink plus the last mapping drop.

close() is idempotent. An owned private object collected without close() emits ResourceWarning.

Pickle

copy.copy / copy.deepcopy raise TypeError. Process-private handles raise TypeError on pickle.

Bound shared handles pickle as a capability: _attach_slot(name, slot, kind, digest). MutexArray also stores count. Queue / FutexQueue also store depth and item_size. Unpickle checks that slot's kind and the digest, not the full directory. on_owner_died is not in the payload. An unpickled Condition rebuilds its mutex with on_owner_died=None.

EventFD and MemFD pickle via multiprocessing.reduction.DupFd.

Preferred spawn path: same factory in both processes so recovery is registered again.

def make_app(recover):
    layout = posixipc.Layout()
    mutex = layout.add(posixipc.RobustMutex, on_owner_died=recover)
    state = layout.add_bytes(4096)
    return layout, mutex, state

layout, mutex, state = make_app(recover)
layout.create(name)

# child
layout, mutex, state = make_app(recover)
layout.attach(name)

If you pickle the handle, assign mutex.on_owner_died = recover before the first acquire.

SharedMemory.__reduce__ pickles the name and re-attaches unchecked after the same uid/mode fstat. Pickle layout handles, not the raw region.

Fork

No pthread_atfork handlers.

Object fork()
Shared, unlocked Supported. Create the segment before forking.
Shared robust mutex held by the parent Parent still owns it. Child must not unlock or acquire. Child exit does not produce EOWNERDEAD for the parent.
Private, unlocked, single-threaded fork Child gets a copy. Avoid.
Private, held Unsupported.
Multithreaded fork Unsupported except fork-then-exec.
Named semaphore Handle inherited; counts shared.
SharedMemory mapping Inherited; re-attach by name under spawn.

Prefer spawn or forkserver.

Security

A writable POSIX shm object is trusted to every uid that can open it. Header checks catch accidents. They do not make a hostile pthread_mutex_t safe.

create() uses 0600 and requires st_uid == geteuid() and no group/other write. attach() does the same. A same-uid squat is still possible; use an unpredictable name or create from a supervisor.

SharedMemory buffer protocol is read-only on the header and directory. Writable bytes are region.payload, after the directory. Writing a primitive slot through a memoryview is unsupported.

C API

Python call overhead dominates an uncontended lock. Other extensions can lock through a capsule.

  • PyCapsule posixipc.mutex.v1 → pthread_mutex_t *, retains a pin.
  • Installed posixipc.h: header layout, slot record, capsule accessors.
  • region.payload as a memoryview.
  • MutexArray: arr.acquire(i) / arr[i] / arr.as_capsule(i).
locks = layout.add_array(posixipc.Mutex, 32)
locks.acquire(0)
locks.release(0)
with locks[3]:
    pass
cap = locks.as_capsule(0)
#include <posixipc.h>

pthread_mutex_t *m = posixipc_mutex_from_capsule(capsule);
posixipc_mutex_capsule_retain(capsule);
/* pthread_mutex_lock(m) */
posixipc_mutex_capsule_release(capsule);

Primitives

Class Backing Robust Notes
Mutex pthread_mutex_t no ERRORCHECK. prio_inherit optional; no effect on SCHED_OTHER.
RobustMutex pthread_mutex_t yes Requires on_owner_died.
RWLock pthread_rwlock_t no acquire_read / acquire_write / read() / write(). Crash holding write wedges it.
Condition pthread_cond_t no Monotonic. Always while. mutex= required at add(). Shared cond + mutex must be in the same layout. wait() runs mutex recovery on EOWNERDEAD. blocking=False is ValueError.
Semaphore unnamed sem_t no release(n=1). sem_post is async-signal-safe in C, not from signal.signal.
NamedSemaphore sem_open no create / attach / unlink (no open_or_create). Crashed holder does not release. Absent if undetected.
MutexArray N mutex slots * add_array. arr[i] is MutexArrayItem. Robust if kind is RobustMutex.
SharedBytes bytes slot no Return of add_bytes. Buffer protocol.
Queue layout ring yes Five slots. CS is the put lock. Library recover. Exact item_size bytes. No process-private Queue().
linux.FutexQueue layout ring + futex yes Linux. Three slots. Digest ≠ Queue.
linux.Futex FUTEX_WAIT / WAKE no Process-private wait(expected) / wake(n). Not a layout kind.
linux.EventFD eventfd(2) no write / read / fileno. semaphore= / nonblock=. Pickle via DupFd.
linux.MemFD memfd_create no memfd(size) is MemFD.create. memoryview / .payload. Pickle via fd.
NamedMessageQueue mq_* no create / attach / open_or_create / unlink. Existing name keeps maxmsg/msgsize. Notify callback runs on the notify thread. Not a layout kind. Absent if undetected.
Barrier pthread_barrier_t no wait() is True for one waiter. Not interruptible. Crash hangs the rest. Absent if undetected.
SpinLock pthread_spinlock_t no trylock + yield, GIL released. No timeout. Import posixipc.spinlock.

No Mutex.locked(). POSIX cannot query a mutex without racing.


Performance

Uncontended acquire+release is a Python call plus a native lock. On this machine posixipc.Mutex is faster than threading.Lock because threading.Lock is not a raw mutex: CPython 3.12 implements it as a POSIX semaphore plus argument parsing and a non-blocking try that still sets up a timeout. posixipc.Mutex does pthread_mutex_trylock with the GIL held. It matches multiprocessing.Lock (also a SemLock).

Numbers: benchmarks/RESULTS.md. Re-run with python benchmarks/mutex_uncontended.py (taskset if you can).


Platform

Tier Platform
1 Linux x86-64, glibc, CPython 3.12–3.14
2 Linux aarch64, glibc
2 Linux x86-64, musl
3 Other POSIX, feature-detected, not in CI

musl is a different pthread implementation (often the same sizeof) and lacks *_clocklock. It does implement pthread_barrier_*.

WSL is Linux. Native Windows is a separate backend (WAIT_ABANDONED + file mapping), not this tree under MSVC. Not in CI.


Building

pip install .
# or
pip install -e .

CMake via scikit-build-core. Links Development.Module only, not libpython. Clock functions are dlsym'd at import.

C tests are off unless you ask:

cmake -B build -DBUILD_TESTING=ON -DPOSIXIPC_DEVELOPER_MODE=ON
cmake --build build
ctest --test-dir build

POSIXIPC_DEVELOPER_MODE turns on -Werror. Formatters: Ruff for Python, .clang-format for C (src/, include/, tests_c/).

POSIXIPC_SANITIZER = address | thread | undefined. POSIXIPC_CACHELINE_BYTES defaults to 64; changing it changes abi_tag and the digest.


Testing

tests_c/ has no Python dependency (usable under TSan). TSan is single-process; fork() TSan runs do not prove cross-process races. Those need ASan/UBSan plus the stress loops.

Robust tests SIGKILL a child after it publishes a hold flag. Owner death is not simulated with release(). Every test has a timeout.

pytest tests/

tox runs the same pytest on each installed CPython from 3.12 up. Missing interpreters are skipped. Free-threaded envs (py313t, py314t) need a 3.13t / 3.14t interpreter (see GIL and free-threaded CPython).

pip install 'tox~=4.26'
tox                 # py312, py313, py314, and t builds if present
tox -e py312        # one version
tox -e py313t,py314t

License

MIT. See LICENSE.

About

POSIX IPC and synchronization primitives for Python

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages