SharedMemoryPipe¶
A bounded, single-producer / single-consumer byte channel built on POSIX shared memory and POSIX named semaphores. It moves bulk binary data between processes without pickling, copying, or routing through the event system.
Problem¶
Normal EDRI events travel as pickled objects through multiprocessing.Queue, the Router, the Broker, and Managers. For hundreds of megabytes of binary data — file downloads, generated archives, streamed uploads — this path is too slow and consumes too much memory.
SharedMemoryPipe sidesteps all of it: payload bytes go straight into a shared memory segment. Only a small reader handle (a few integers and a name string) travels through the event system on event.response.file or event.file.path.
Architecture¶
The shared memory region is divided into a ring of fixed-size slots. Each slot has a small binary header followed by a payload region.
┌──────────────────────────────────────────────────┐
│ SharedMemory segment │
├─────────┬─────────┬─────────┬─────────┬─────────┤
│ Slot 0 │ Slot 1 │ Slot 2 │ Slot 3 │ ... │
├─────────┴─────────┴─────────┴─────────┴─────────┤
│ Each slot: [ HEADER (8 B) | PAYLOAD (variable) ] │
│ HEADER = uint32 size + uint8 eof + 3B pad │
└──────────────────────────────────────────────────┘
Slot state cycle¶
┌──────────┐
│ Empty │ ← slots semaphore counts these
└────┬─────┘
│ writer acquires a free slot
▼
┌──────────┐
│ Writing │ ← writer fills header + payload
└────┬─────┘
│ items.release()
▼
┌──────────┐
│ Ready │ ← items semaphore counts these
└────┬─────┘
│ reader acquires a filled slot
▼
┌──────────┐
│ Reading │ ← reader copies payload out
└────┬─────┘
│ slots.release()
▼
┌──────────┐
│ Empty │
└──────────┘
Key parameters¶
Parameter |
Default |
Purpose |
|---|---|---|
|
524288 (512 KiB) |
Minimum slot size (including header). |
|
64 |
Number of slots in the ring. More slots = more buffering, less contention. |
|
|
Expected total payload. When set, slot size is computed so the whole payload fits in ≤ |
Total shared memory consumed is slot_size × max_slots.
Producer/consumer model¶
There are exactly two roles, and each pipe has exactly one endpoint of each role.
Writer¶
Created by SharedMemoryPipe() — the constructor with no name argument. The writer:
Creates the shared memory segment and two POSIX semaphores.
Owns creation and cannot be pickled.
Writes data in chunks via
write(data)and signals EOF viawrite(data, close=True)orclose().
Reader¶
Created by writer.reader(). The reader:
Attaches to the existing shared memory and semaphores by name.
Can be pickled — it travels through the event system as a handle.
Reads chunks via
read()or iteration; returnsNone/ stops at EOF.Unlinks the shared memory when exhausted.
# Writer side (stays in the producing process)
pipe = SharedMemoryPipe() # creates SHM + semaphores
reader = pipe.reader() # picklable handle
# Send the reader to the consuming process via an event
event.response.file = File(path=reader)
self.event_send(event)
# Write data — blocks if the buffer is full (backpressure)
pipe.write(chunk1)
pipe.write(chunk2)
pipe.close() # sends EOF
# Reader side (in the consuming process)
for chunk in reader:
sink.write(chunk) # stops at EOF, unlinks SHM
EOF signal¶
EOF is an empty slot whose header has eof=1. It is written by close() and ensures the reader observes every byte before stopping.
Code example: Worker → SharedMemoryPipe → API download¶
The following producer runs inside a WorkerProcess. It generates data and streams it through shared memory directly to the HTTP client.
from edri.abstract.worker import WorkerProcess
from edri.api.dataclass import File
from edri.utility.shared_memory_pipe import SharedMemoryPipe
class ReportWorker(WorkerProcess[GenerateReport]):
def do(self) -> None:
with SharedMemoryPipe() as smp: # writer
self.event.response.file = File(
file_name="report.csv",
mime_type="text/csv",
path=smp.reader(), # picklable handle
)
self.event_send(self.event) # API starts serving now
for chunk in produce_rows(): # blocks on backpressure
smp.write(chunk)
The consuming side uses SharedMemoryPipeFile (or iterates the reader directly):
for chunk in pipe_reader:
response_body.write(chunk)
See the worked example in SharedMemoryPipe: streaming a ZIP of a folder.
Performance characteristics¶
Zero copy between processes — the writer writes directly into the shared segment; the reader reads directly from it.
Constant memory — the ring buffer has a fixed size regardless of payload, so a 10 GB stream uses the same memory as a 1 MB stream.
Automatic backpressure — the
slotsanditemssemaphores block the producer when the consumer is slow and vice-versa.No kernel involvement for data after creation — no pipes, no sockets, no
sendmsg/recvmsg.
Safety guarantees¶
Exactly one writer, one reader. Creating multiple readers from the same pipe raises
RuntimeError; pickling the writer raisesRuntimeError.Idempotent
close(). Both ends can be closed multiple times safely.Context manager support — use
with SharedMemoryPipe() as smp:.Single host only. POSIX shared memory does not work across machines.
Re-enables the GIL on a free-threaded build.
posix_ipchas not declared free-threading support; importing this module on a3.13t+ interpreter re-enables the GIL for the whole process. See Performance & scaling ("Free-threaded CPython").
See also¶
Worker — SharedMemoryPipe in context of the Worker model
SharedMemoryPipe: streaming a ZIP of a folder — streaming ZIP example
Downloading files — file download responses