py-sharedmemory 4.1.1__tar.gz → 4.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/CHANGELOG.md +32 -0
  2. {py_sharedmemory-4.1.1/py_sharedmemory.egg-info → py_sharedmemory-4.2.1}/PKG-INFO +40 -24
  3. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/README.md +39 -23
  4. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory/__init__.py +1 -1
  5. py_sharedmemory-4.2.1/py_sharedmemory/convert.py +155 -0
  6. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory/receiver.py +10 -8
  7. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory/sender.py +107 -31
  8. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1/py_sharedmemory.egg-info}/PKG-INFO +40 -24
  9. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/SOURCES.txt +1 -0
  10. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/conftest.py +2 -0
  11. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/helpers.py +11 -1
  12. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_convert.py +145 -8
  13. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_queue.py +108 -33
  14. py_sharedmemory-4.2.1/tests/test_recycling.py +253 -0
  15. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_threads.py +4 -4
  16. py_sharedmemory-4.1.1/py_sharedmemory/convert.py +0 -110
  17. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/LICENSE +0 -0
  18. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/MANIFEST.in +0 -0
  19. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/benchmarks/__init__.py +0 -0
  20. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/benchmarks/benchmark.py +0 -0
  21. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/benchmarks/plot.py +0 -0
  22. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/benchmarks/results/windows-numpy.json +0 -0
  23. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/benchmarks/results/windows-numpy.png +0 -0
  24. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/examples/basic.py +0 -0
  25. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/examples/numpy_batches.py +0 -0
  26. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/examples/worker_pool.py +0 -0
  27. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory/factory.py +0 -0
  28. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory/py.typed +0 -0
  29. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/dependency_links.txt +0 -0
  30. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/requires.txt +0 -0
  31. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/top_level.txt +0 -0
  32. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/pyproject.toml +0 -0
  33. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/setup.cfg +0 -0
  34. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/__init__.py +0 -0
  35. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_benchmark.py +0 -0
  36. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_objects.py +0 -0
  37. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_package.py +0 -0
  38. {py_sharedmemory-4.1.1 → py_sharedmemory-4.2.1}/tests/test_processes.py +0 -0
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.1
4
+
5
+ - `put` copies large `bytes` and `bytearray` objects (64 KiB and up) straight into shared
6
+ memory, wherever they appear in a message. It used to build the complete pickle in
7
+ memory first: an extra copy of the payload that briefly needed up to 1.5 times its size
8
+ (48 MiB for a 32 MiB `bytearray`). Strided NumPy arrays, which NumPy pickles as `bytes`,
9
+ skip that copy too. The message format is unchanged.
10
+ - Resizing a `bytearray` while its message is being pickled, from a `__reduce__` method,
11
+ now raises `BufferError` instead of risking a corrupted message.
12
+
13
+ ## 4.2.0
14
+
15
+ - Reuse received shared-memory blocks instead of freeing them. Writing into fresh pages
16
+ cost far more than the copy itself, so large `put` calls are now much faster. A block
17
+ is reused for a message that fills at least half of it, or for any message that fits a
18
+ block of up to 1 MiB. The sender keeps at most 8 blocks and 256 MiB for reuse, never
19
+ more blocks than the capacity allows, and frees them on `close()`, so peak memory is
20
+ unchanged. On Linux and macOS at most 8 unread blocks stay mapped (two file
21
+ descriptors each).
22
+ - Acknowledgements carry a per-message sequence number, so an unknown or repeated
23
+ acknowledgement can never release a reused block that holds a newer message.
24
+ - Fix receivers hanging forever in `close()` or at process exit after reading many
25
+ messages that a closed sender had already freed: such messages are no longer
26
+ acknowledged, so the acknowledgement pipe can no longer fill up.
27
+ - Fix a Windows race where a message freed while a receiver attached to it raised a
28
+ confusing `UnpicklingError`; it now raises the usual "freed before it was received"
29
+ `FileNotFoundError`.
30
+ - Received NumPy arrays are always aligned. Arrays that followed an odd-sized buffer in the
31
+ same message used to arrive unaligned, which slows NumPy and breaks libraries that need
32
+ aligned memory. Each array now owns its memory, so keeping one no longer keeps the
33
+ whole message alive.
34
+
3
35
  ## 4.1.1
4
36
 
5
37
  - Unread messages no longer hold file descriptors on Linux and macOS: the sender unmaps
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sharedmemory
3
- Version: 4.1.1
3
+ Version: 4.2.1
4
4
  Summary: Fast multiprocessing.Queue alternative that moves large Python objects and NumPy arrays through shared memory
5
5
  Author: Tobias Würth
6
6
  License-Expression: MIT
@@ -68,10 +68,11 @@ images, and simulation states arrive up to 2× faster, and transfers too large f
68
68
 
69
69
  - **Familiar API:** `put` / `get` with `block`, `timeout`, `put_nowait` / `get_nowait`,
70
70
  `queue.Full`, and `queue.Empty`.
71
- - **Any picklable object:** NumPy arrays and other buffers use pickle protocol 5
72
- out-of-band buffers, so they are copied straight into shared memory.
73
- - **Bounded memory:** a capacity limit applies backpressure, and each shared-memory
74
- block is freed once its message is received.
71
+ - **Any picklable object:** NumPy arrays, other protocol-5 buffers, and large `bytes` and
72
+ `bytearray` objects are copied straight into shared memory, without an intermediate
73
+ pickle copy.
74
+ - **Bounded memory:** a capacity limit applies backpressure, and received blocks are
75
+ reused for later messages, so shared memory stays within `capacity × message size`.
75
76
  - **One producer, many consumers:** consumer processes and threads share one receiver.
76
77
  - **Portable:** Linux, macOS, and Windows; `spawn`, `fork`, and `forkserver`; Python 3.11–3.15.
77
78
  - **Lightweight:** no dependencies, fully typed (PEP 561), and 100% test coverage.
@@ -131,8 +132,8 @@ arrays, image or video frames, RL rollouts, large `bytes`, or big nested objects
131
132
  helps where large `multiprocessing.Queue` transfers cause heavy pipe I/O or uneven
132
133
  throughput, which is especially noticeable on Windows.
133
134
 
134
- Prefer `multiprocessing.Queue` for small messages. Below about 100 kB, creating and
135
- freeing a shared-memory block (about 0.09 ms per message) costs more than the pipe.
135
+ Prefer `multiprocessing.Queue` for small messages. Below about 100 kB, the fixed cost of
136
+ mapping a shared-memory block for every message outweighs the pipe.
136
137
 
137
138
  To let several processes read and write **the same** array in place, use
138
139
  [`multiprocessing.shared_memory`](https://docs.python.org/3/library/multiprocessing.shared_memory.html)
@@ -155,20 +156,25 @@ Core i9-14900K, 191.8 GiB RAM, Windows 11, Python 3.12.10, NumPy 2.5.3, measured
155
156
  2026-10-08. Contiguous float32 arrays, `spawn`, capacity 2, median of 5 runs excluding
156
157
  startup and warmup; up to 1,000 messages or 512 MiB per run, at least one message.
157
158
  1 GB = 10⁹ bytes. See the [raw results](https://github.com/tobiaswuerth/python_shared_memory_queue/blob/main/benchmarks/results/windows-numpy.json).
158
- These numbers show transfer speed; I/O load and stability depend on the workload.
159
+ These numbers show transfer speed; I/O load and stability depend on the workload. They
160
+ predate version 4.2, which reuses received blocks and so no longer pays to fault in fresh
161
+ pages on every `put`.
159
162
 
160
163
  ## How it works
161
164
 
162
- 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data,
163
- stay out-of-band, and everything is copied into one new shared-memory block.
164
- 2. Only the block's name and buffer sizes travel through a `multiprocessing.Queue`.
165
+ 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data and
166
+ `bytes` or `bytearray` objects of 64 KiB or more, never enter the pickle; everything is copied once,
167
+ from the original objects into one shared-memory block: a block from an earlier,
168
+ received message when one fits, otherwise a new one.
169
+ 2. Only the block's name, buffer sizes, and a sequence number travel through a
170
+ `multiprocessing.Queue`.
165
171
  3. `get()` attaches to the block, copies the data into local memory, unpickles it, and
166
172
  sends an acknowledgement back.
167
- 4. A background thread in the producer frees the block when the acknowledgement
168
- arrives. `capacity` limits how many blocks can be outstanding at once.
173
+ 4. A background thread in the producer makes the block available for reuse when the
174
+ acknowledgement arrives. `capacity` limits how many messages can be outstanding.
169
175
 
170
176
  Received objects never point into shared memory, so they stay valid after the block is
171
- freed and can be modified freely.
177
+ reused or freed and can be modified freely.
172
178
 
173
179
  ## Usage guide
174
180
 
@@ -188,9 +194,15 @@ consumer = ctx.Process(target=consume, args=(receiver,))
188
194
 
189
195
  ### Capacity and backpressure
190
196
 
191
- `capacity` counts messages that were sent but not yet received and freed. When it is
192
- reached, `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is
193
- at most about `capacity × message size`. `capacity=0` or `None` means unbounded.
197
+ `capacity` counts messages that were sent but not yet received. When it is reached,
198
+ `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is at most
199
+ about `capacity × message size`. `capacity=0` or `None` means unbounded.
200
+
201
+ Received blocks are reused rather than freed, because writing into memory that is
202
+ already mapped is much faster than faulting in fresh pages. A block is reused for a
203
+ message that fills at least half of it, or for any message that fits a block of up to
204
+ 1 MiB. The sender keeps at most 8 such blocks and 256 MiB, never more blocks than the
205
+ capacity allows, and frees them on `close()`. Blocks over 256 MiB are freed once received.
194
206
 
195
207
  ### Timeouts and non-blocking calls
196
208
 
@@ -241,7 +253,9 @@ such as `None` is a simple way to tell consumers that the stream has ended.
241
253
  Arrays keep their dtype, shape, and writability. Read-only arrays stay read-only, and
242
254
  writable ones arrive as writable local copies. C- and Fortran-contiguous arrays keep
243
255
  their layout and are copied straight into shared memory without an intermediate pickle
244
- copy; other views, such as strided slices, arrive as contiguous copies.
256
+ copy; other views, such as strided slices, arrive as contiguous copies. Each received
257
+ array has its own aligned memory, so keeping one array does not keep the rest of the
258
+ message alive.
245
259
 
246
260
  ### Errors
247
261
 
@@ -250,8 +264,9 @@ copy; other views, such as strided slices, arrive as contiguous copies.
250
264
  | `queue.Full` | `put` found no capacity within `timeout` or with `block=False`. |
251
265
  | `queue.Empty` | `get` found no message within `timeout` or with `block=False`. |
252
266
  | `BrokenPipeError` | The endpoint is closed, or the producer's acknowledgement channel failed. |
267
+ | `FileNotFoundError` | `get` received a message that the producer freed by closing before it was read. |
253
268
  | `RuntimeError` | A second process tries to produce, or a used sender is passed to another process. |
254
- | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still freed. |
269
+ | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still released. |
255
270
  | `RuntimeWarning` (warning) | A `with sender:` block or the producing process ended normally and discarded unacknowledged messages. |
256
271
 
257
272
  ## API reference
@@ -266,10 +281,10 @@ for the underlying queues; it defaults to `multiprocessing.get_context()`.
266
281
 
267
282
  | Member | Description |
268
283
  | --- | --- |
269
- | `put(obj, block=True, timeout=None)` | Copy `obj` into a new shared-memory block and send it. |
284
+ | `put(obj, block=True, timeout=None)` | Copy `obj` into a shared-memory block and send it. |
270
285
  | `put_nowait(obj)` | `put(obj, block=False)`. |
271
- | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and freed; `False` on timeout. |
272
- | `close()` | Free unread messages silently and release resources. Runs automatically at normal process exit. |
286
+ | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and its block released; `False` on timeout. |
287
+ | `close()` | Free unread messages and reusable blocks silently and release resources. Runs automatically at normal process exit. |
273
288
  | `closed` | Whether the sender is closed. |
274
289
  | `with sender:` | Closes the sender on exit; warns if a clean exit discards unacknowledged messages. |
275
290
 
@@ -308,8 +323,9 @@ the last handle closes, so nothing can leak after a crash.
308
323
  producer closed or exited before this message was read. Call `wait_for_all_ack()` before
309
324
  closing the sender; the sender also warns when this happens.
310
325
 
311
- **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1,
312
- where unread messages no longer hold file descriptors.
326
+ **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1.
327
+ On Linux and macOS the sender keeps at most 8 blocks mapped for reuse, two file
328
+ descriptors each; other unread messages hold none.
313
329
 
314
330
  **`RuntimeError: A shared-memory pair supports one producing process`:** a second
315
331
  process called `put`. Create a separate pair for each producer process.
@@ -18,10 +18,11 @@ images, and simulation states arrive up to 2× faster, and transfers too large f
18
18
 
19
19
  - **Familiar API:** `put` / `get` with `block`, `timeout`, `put_nowait` / `get_nowait`,
20
20
  `queue.Full`, and `queue.Empty`.
21
- - **Any picklable object:** NumPy arrays and other buffers use pickle protocol 5
22
- out-of-band buffers, so they are copied straight into shared memory.
23
- - **Bounded memory:** a capacity limit applies backpressure, and each shared-memory
24
- block is freed once its message is received.
21
+ - **Any picklable object:** NumPy arrays, other protocol-5 buffers, and large `bytes` and
22
+ `bytearray` objects are copied straight into shared memory, without an intermediate
23
+ pickle copy.
24
+ - **Bounded memory:** a capacity limit applies backpressure, and received blocks are
25
+ reused for later messages, so shared memory stays within `capacity × message size`.
25
26
  - **One producer, many consumers:** consumer processes and threads share one receiver.
26
27
  - **Portable:** Linux, macOS, and Windows; `spawn`, `fork`, and `forkserver`; Python 3.11–3.15.
27
28
  - **Lightweight:** no dependencies, fully typed (PEP 561), and 100% test coverage.
@@ -81,8 +82,8 @@ arrays, image or video frames, RL rollouts, large `bytes`, or big nested objects
81
82
  helps where large `multiprocessing.Queue` transfers cause heavy pipe I/O or uneven
82
83
  throughput, which is especially noticeable on Windows.
83
84
 
84
- Prefer `multiprocessing.Queue` for small messages. Below about 100 kB, creating and
85
- freeing a shared-memory block (about 0.09 ms per message) costs more than the pipe.
85
+ Prefer `multiprocessing.Queue` for small messages. Below about 100 kB, the fixed cost of
86
+ mapping a shared-memory block for every message outweighs the pipe.
86
87
 
87
88
  To let several processes read and write **the same** array in place, use
88
89
  [`multiprocessing.shared_memory`](https://docs.python.org/3/library/multiprocessing.shared_memory.html)
@@ -105,20 +106,25 @@ Core i9-14900K, 191.8 GiB RAM, Windows 11, Python 3.12.10, NumPy 2.5.3, measured
105
106
  2026-10-08. Contiguous float32 arrays, `spawn`, capacity 2, median of 5 runs excluding
106
107
  startup and warmup; up to 1,000 messages or 512 MiB per run, at least one message.
107
108
  1 GB = 10⁹ bytes. See the [raw results](https://github.com/tobiaswuerth/python_shared_memory_queue/blob/main/benchmarks/results/windows-numpy.json).
108
- These numbers show transfer speed; I/O load and stability depend on the workload.
109
+ These numbers show transfer speed; I/O load and stability depend on the workload. They
110
+ predate version 4.2, which reuses received blocks and so no longer pays to fault in fresh
111
+ pages on every `put`.
109
112
 
110
113
  ## How it works
111
114
 
112
- 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data,
113
- stay out-of-band, and everything is copied into one new shared-memory block.
114
- 2. Only the block's name and buffer sizes travel through a `multiprocessing.Queue`.
115
+ 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data and
116
+ `bytes` or `bytearray` objects of 64 KiB or more, never enter the pickle; everything is copied once,
117
+ from the original objects into one shared-memory block: a block from an earlier,
118
+ received message when one fits, otherwise a new one.
119
+ 2. Only the block's name, buffer sizes, and a sequence number travel through a
120
+ `multiprocessing.Queue`.
115
121
  3. `get()` attaches to the block, copies the data into local memory, unpickles it, and
116
122
  sends an acknowledgement back.
117
- 4. A background thread in the producer frees the block when the acknowledgement
118
- arrives. `capacity` limits how many blocks can be outstanding at once.
123
+ 4. A background thread in the producer makes the block available for reuse when the
124
+ acknowledgement arrives. `capacity` limits how many messages can be outstanding.
119
125
 
120
126
  Received objects never point into shared memory, so they stay valid after the block is
121
- freed and can be modified freely.
127
+ reused or freed and can be modified freely.
122
128
 
123
129
  ## Usage guide
124
130
 
@@ -138,9 +144,15 @@ consumer = ctx.Process(target=consume, args=(receiver,))
138
144
 
139
145
  ### Capacity and backpressure
140
146
 
141
- `capacity` counts messages that were sent but not yet received and freed. When it is
142
- reached, `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is
143
- at most about `capacity × message size`. `capacity=0` or `None` means unbounded.
147
+ `capacity` counts messages that were sent but not yet received. When it is reached,
148
+ `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is at most
149
+ about `capacity × message size`. `capacity=0` or `None` means unbounded.
150
+
151
+ Received blocks are reused rather than freed, because writing into memory that is
152
+ already mapped is much faster than faulting in fresh pages. A block is reused for a
153
+ message that fills at least half of it, or for any message that fits a block of up to
154
+ 1 MiB. The sender keeps at most 8 such blocks and 256 MiB, never more blocks than the
155
+ capacity allows, and frees them on `close()`. Blocks over 256 MiB are freed once received.
144
156
 
145
157
  ### Timeouts and non-blocking calls
146
158
 
@@ -191,7 +203,9 @@ such as `None` is a simple way to tell consumers that the stream has ended.
191
203
  Arrays keep their dtype, shape, and writability. Read-only arrays stay read-only, and
192
204
  writable ones arrive as writable local copies. C- and Fortran-contiguous arrays keep
193
205
  their layout and are copied straight into shared memory without an intermediate pickle
194
- copy; other views, such as strided slices, arrive as contiguous copies.
206
+ copy; other views, such as strided slices, arrive as contiguous copies. Each received
207
+ array has its own aligned memory, so keeping one array does not keep the rest of the
208
+ message alive.
195
209
 
196
210
  ### Errors
197
211
 
@@ -200,8 +214,9 @@ copy; other views, such as strided slices, arrive as contiguous copies.
200
214
  | `queue.Full` | `put` found no capacity within `timeout` or with `block=False`. |
201
215
  | `queue.Empty` | `get` found no message within `timeout` or with `block=False`. |
202
216
  | `BrokenPipeError` | The endpoint is closed, or the producer's acknowledgement channel failed. |
217
+ | `FileNotFoundError` | `get` received a message that the producer freed by closing before it was read. |
203
218
  | `RuntimeError` | A second process tries to produce, or a used sender is passed to another process. |
204
- | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still freed. |
219
+ | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still released. |
205
220
  | `RuntimeWarning` (warning) | A `with sender:` block or the producing process ended normally and discarded unacknowledged messages. |
206
221
 
207
222
  ## API reference
@@ -216,10 +231,10 @@ for the underlying queues; it defaults to `multiprocessing.get_context()`.
216
231
 
217
232
  | Member | Description |
218
233
  | --- | --- |
219
- | `put(obj, block=True, timeout=None)` | Copy `obj` into a new shared-memory block and send it. |
234
+ | `put(obj, block=True, timeout=None)` | Copy `obj` into a shared-memory block and send it. |
220
235
  | `put_nowait(obj)` | `put(obj, block=False)`. |
221
- | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and freed; `False` on timeout. |
222
- | `close()` | Free unread messages silently and release resources. Runs automatically at normal process exit. |
236
+ | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and its block released; `False` on timeout. |
237
+ | `close()` | Free unread messages and reusable blocks silently and release resources. Runs automatically at normal process exit. |
223
238
  | `closed` | Whether the sender is closed. |
224
239
  | `with sender:` | Closes the sender on exit; warns if a clean exit discards unacknowledged messages. |
225
240
 
@@ -258,8 +273,9 @@ the last handle closes, so nothing can leak after a crash.
258
273
  producer closed or exited before this message was read. Call `wait_for_all_ack()` before
259
274
  closing the sender; the sender also warns when this happens.
260
275
 
261
- **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1,
262
- where unread messages no longer hold file descriptors.
276
+ **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1.
277
+ On Linux and macOS the sender keeps at most 8 blocks mapped for reuse, two file
278
+ descriptors each; other unread messages hold none.
263
279
 
264
280
  **`RuntimeError: A shared-memory pair supports one producing process`:** a second
265
281
  process called `put`. Create a separate pair for each producer process.
@@ -25,5 +25,5 @@ from .factory import create_shared_memory_pair
25
25
  from .receiver import SharedMemoryReceiver
26
26
  from .sender import SharedMemorySender
27
27
 
28
- __version__ = "4.1.1"
28
+ __version__ = "4.2.1"
29
29
  __all__ = ["SharedMemoryReceiver", "SharedMemorySender", "__version__", "create_shared_memory_pair"]
@@ -0,0 +1,155 @@
1
+ """Serialize protocol-5 pickle data through a single shared-memory block."""
2
+
3
+ import errno
4
+ import pickle
5
+ import sys
6
+ from collections.abc import Callable
7
+ from contextlib import suppress
8
+ from multiprocessing.shared_memory import SharedMemory
9
+ from typing import Any, NamedTuple, cast
10
+
11
+ # Receivers never unlink. Tracking their attachments would leak tracker entries and
12
+ # warnings when a receiver does not share the sender's resource tracker.
13
+ _ATTACH_OPTIONS: dict[str, Any] = {"track": False} if sys.version_info >= (3, 13) else {}
14
+
15
+
16
+ class SharedMemoryMessage(NamedTuple):
17
+ name: str
18
+ buffer_sizes: tuple[int, ...]
19
+ # Unique per sender. Blocks are reused, so acknowledgements name the message, not the block.
20
+ sequence: int = 0
21
+
22
+ @property
23
+ def size(self) -> int:
24
+ return sum(self.buffer_sizes)
25
+
26
+
27
+ def create_shared_memory(size: int) -> SharedMemory:
28
+ return SharedMemory(create=True, size=size)
29
+
30
+
31
+ def release_shared_memory(allocation: SharedMemory) -> None:
32
+ """Release both the mapping and its name, including on error paths."""
33
+ try:
34
+ allocation.close()
35
+ finally:
36
+ with suppress(FileNotFoundError):
37
+ allocation.unlink()
38
+
39
+
40
+ class _PickleChunks(list[memoryview]):
41
+ """Pickle output kept as views of what pickle wrote rather than one joined copy."""
42
+
43
+ def write(self, data: Any) -> None:
44
+ # Pickle passes bytes and bytearrays of 64 KiB or more as the objects themselves.
45
+ # A view also stops a bytearray from being resized before it is copied, which would
46
+ # leave its length header in the stream wrong.
47
+ self.append(memoryview(data))
48
+
49
+
50
+ def serialize(
51
+ data: Any,
52
+ sequence: int = 0,
53
+ allocate: Callable[[int], SharedMemory] = create_shared_memory,
54
+ ) -> tuple[SharedMemory, SharedMemoryMessage]:
55
+ """Copy a pickle and its out-of-band buffers into an owned block of at least that size.
56
+
57
+ Large bytes, bytearrays, and contiguous arrays are copied once, straight from the
58
+ original objects into shared memory. `allocate` returns a new or reused block; this
59
+ function frees it if copying fails.
60
+ """
61
+ chunks = _PickleChunks()
62
+ buffers: list[memoryview] = []
63
+ allocation = None
64
+
65
+ def append_buffer(buffer: pickle.PickleBuffer) -> None:
66
+ buffers.append(buffer.raw())
67
+
68
+ try:
69
+ pickle.Pickler(chunks, protocol=5, buffer_callback=append_buffer).dump(data)
70
+ pickle_size = sum(chunk.nbytes for chunk in chunks)
71
+ buffer_sizes = (pickle_size, *(buffer.nbytes for buffer in buffers))
72
+ allocation = allocate(sum(buffer_sizes))
73
+ shared = cast(memoryview, allocation.buf)
74
+ offset = 0
75
+ for view in (*chunks, *buffers):
76
+ shared[offset : offset + view.nbytes] = view
77
+ offset += view.nbytes
78
+ return allocation, SharedMemoryMessage(allocation.name, buffer_sizes, sequence)
79
+ except BaseException:
80
+ if allocation is not None:
81
+ release_shared_memory(allocation)
82
+ raise
83
+ finally:
84
+ for view in (*chunks, *buffers):
85
+ view.release()
86
+
87
+
88
+ def deserialize(message: SharedMemoryMessage) -> Any:
89
+ """Unpickle with owned local buffers and always close the shared mapping."""
90
+ return load(message, attach(message))
91
+
92
+
93
+ def attach(message: SharedMemoryMessage) -> SharedMemory:
94
+ """Map the block that valid metadata names, or explain why it no longer exists."""
95
+ _validate_message(message)
96
+ try:
97
+ allocation = SharedMemory(name=message.name, **_ATTACH_OPTIONS)
98
+ except FileNotFoundError as error:
99
+ raise _freed_block_error(message) from error
100
+ # Attaching on Windows is not atomic: a name freed in between is recreated as a new,
101
+ # zero-filled block. Every pickle starts with a nonzero opcode.
102
+ if cast(memoryview, allocation.buf)[0] == 0:
103
+ allocation.close()
104
+ raise _freed_block_error(message)
105
+ return allocation
106
+
107
+
108
+ def _freed_block_error(message: SharedMemoryMessage) -> FileNotFoundError:
109
+ return FileNotFoundError(
110
+ errno.ENOENT,
111
+ "Shared-memory block was freed before it was received; the sender probably "
112
+ "closed without calling wait_for_all_ack()",
113
+ message.name,
114
+ )
115
+
116
+
117
+ def load(message: SharedMemoryMessage, allocation: SharedMemory) -> Any:
118
+ """Unpickle an attached block with owned local buffers, then close the mapping."""
119
+ try:
120
+ if message.size > allocation.size:
121
+ raise ValueError("Shared-memory block is smaller than its metadata")
122
+ shared = cast(memoryview, allocation.buf)
123
+ pickle_size = message.buffer_sizes[0]
124
+ buffers = _copy_buffers(shared, pickle_size, message.buffer_sizes[1:])
125
+ with shared[:pickle_size] as pickle_data:
126
+ return pickle.loads(pickle_data, buffers=buffers)
127
+ finally:
128
+ allocation.close()
129
+
130
+
131
+ def _validate_message(message: SharedMemoryMessage) -> None:
132
+ if not isinstance(message, SharedMemoryMessage) or not isinstance(message.name, str):
133
+ raise ValueError("Invalid shared-memory metadata")
134
+ sizes = message.buffer_sizes
135
+ if (
136
+ not message.name
137
+ or type(message.sequence) is not int
138
+ or message.sequence < 0
139
+ or not isinstance(sizes, tuple)
140
+ or not sizes
141
+ or any(type(size) is not int or size < 0 for size in sizes)
142
+ or sizes[0] == 0
143
+ ):
144
+ raise ValueError("Invalid shared-memory metadata")
145
+
146
+
147
+ def _copy_buffers(shared: memoryview, offset: int, sizes: tuple[int, ...]) -> list[memoryview]:
148
+ # A separate writable copy per buffer starts aligned for any dtype and is freed on its own,
149
+ # so a small array cannot keep a large neighbour alive.
150
+ buffers = []
151
+ for size in sizes:
152
+ with shared[offset : offset + size] as shared_buffer:
153
+ buffers.append(memoryview(bytearray(shared_buffer)))
154
+ offset += size
155
+ return buffers
@@ -6,18 +6,18 @@ from multiprocessing.queues import Queue
6
6
  from types import TracebackType
7
7
  from typing import Any, Self
8
8
 
9
- from .convert import SharedMemoryMessage, deserialize
9
+ from .convert import SharedMemoryMessage, attach, load
10
10
 
11
11
 
12
12
  class SharedMemoryReceiver:
13
13
  """Consuming endpoint of a pair; create it with `create_shared_memory_pair`.
14
14
 
15
15
  Several threads and processes may share one receiver; each message is delivered to
16
- exactly one of them. Every message is acknowledged, even when decoding fails, so
17
- the sender can free its memory.
16
+ exactly one of them. Every received block is acknowledged, even when decoding fails,
17
+ so the sender can free its memory.
18
18
  """
19
19
 
20
- def __init__(self, data_queue: Queue[SharedMemoryMessage], ack_queue: Queue[str]) -> None:
20
+ def __init__(self, data_queue: Queue[SharedMemoryMessage], ack_queue: Queue[int]) -> None:
21
21
  self._data_queue = data_queue
22
22
  self._ack_queue = ack_queue
23
23
  self._closed = False
@@ -35,24 +35,26 @@ class SharedMemoryReceiver:
35
35
  timeout: Maximum seconds to wait; `None` waits forever.
36
36
 
37
37
  Returns:
38
- The object, which stays valid after the shared memory is freed. Arrays keep
38
+ The object, which stays valid after the block is reused or freed. Arrays keep
39
39
  their original writability.
40
40
 
41
41
  Raises:
42
42
  queue.Empty: No message arrived in time.
43
43
  BrokenPipeError: The receiver is closed.
44
+ FileNotFoundError: The sender freed the message before it was received.
44
45
 
45
46
  Unpickling errors propagate unchanged; the message is still acknowledged.
46
47
  """
47
48
  if self._closed:
48
49
  raise BrokenPipeError("Receiver is closed")
49
50
  message = self._data_queue.get(block, timeout)
51
+ # A block that is gone was already freed by its sender, which expects no acknowledgement.
52
+ allocation = attach(message)
50
53
  try:
51
- return deserialize(message)
54
+ return load(message, allocation)
52
55
  finally:
53
56
  # Failed decoding must still release the sender's memory and capacity.
54
- if isinstance(message, SharedMemoryMessage):
55
- self._ack_queue.put_nowait(message.name)
57
+ self._ack_queue.put_nowait(message.sequence)
56
58
 
57
59
  def get_nowait(self) -> Any:
58
60
  """Equivalent to `get(block=False)`."""