Sharing Containers Across Processes¶
On-disk Blosc2 containers (SChunk, NDArray, EmbedStore,
DictStore) can be shared by several processes, or several handles in the
same process, under two complementary mechanisms:
SWMR (single writer, multiple readers) — always on, no configuration needed. One process writes, others read and follow along.
Locking (this is what supports multiple concurrent writers, not just one) — opt-in, via
locking=Trueor theBLOSC_LOCKINGenvironment variable. Serializes accesses with a sidecar lock file so several processes can safely write concurrently, or a reader can safely observe a writer mid-mutation.
Both are advisory: they coordinate cooperating Blosc2 handles, not arbitrary processes touching the file. Neither works over a network filesystem (NFS). Neither is a substitute for a transaction log, either: there is no multi-step commit protocol in the container format itself, so a process crashing mid-mutation can leave partial state in the data regardless of whether locking is in use — see Crash safety below for what locking does and does not give you there.
Two runnable, tested examples cover the common multiple-writers patterns end
to end: examples/ndarray/mwmr-mode.py (several processes writing disjoint
regions, and a read-modify-write counter that shows why holding_lock()
is required for that case) and examples/ndarray/mwmr-enlarge.py
(several processes concurrently growing the same array with
append()). Start there if you just want working code; the rest of this
page is the contract behind it.
SWMR without locking¶
A reader handle opened before a writer mutates a container does not see
the change through its cached view automatically — it re-syncs the next time
it touches the container, whether that’s reading data, checking whether a
vlmeta key exists ("name" in schunk.vlmeta), or an explicit
NDArray.refresh() /
SChunk.refresh() call that polls without
reading any data:
import blosc2
import numpy as np
# Writer process
a = blosc2.zeros((10, 10), urlpath="growing.b2nd", mode="w")
# Reader process, opened before the writer grows the array
reader = blosc2.open("growing.b2nd", mode="r")
reader.shape # (10, 10)
# Writer grows and fills the array
a.resize((20, 10))
a[10:20, :] = np.arange(100).reshape(10, 10)
# Reader: any data access re-syncs the cached shape first
reader[15, :] # reads the new row, no reopen needed
reader.shape # (20, 10)
# ... or poll without touching data
reader.refresh() # True if it re-synced, False if already current
This is the classic HDF5-SWMR use case: a writer grows an array (or appends
schunk chunks) while readers keep up with the new extent. Consistency is
per-operation, not a whole-container snapshot — the same weak ordering
HDF5-SWMR offers. See examples/ndarray/swmr-enlarge.py for this pattern
run for real across several reader processes, including how to tell a
“settled” (safely readable) region from a just-grown-but-not-yet-filled one,
and how to retry the occasional transient read error a reader can hit
racing the writer. examples/ndarray/swmr-enlarge-bars.py is the same
scenario rendered live with rich progress bars – one per writer/reader,
each reporting its own throughput – so the writer-leads-readers-follow
effect is visible instead of just asserted; run it in a real terminal.
Contract and limits:
Single writer. Two writers mutating the same container without locking is not supported and can corrupt it.
Staleness is detected from the on-disk container length, so growth (which appends chunks) is virtually always noticed. A mutation that leaves the length unchanged (e.g. updating a chunk in place, or a resize that shrinks within the last chunk) may go undetected until the next mutation that does change the length.
Only shape changes are followed for
NDArray; a handle that changesndim,chunksorblocksthrough another handle makes readers raise instead of silently reading garbage.A reader racing a writer without locking can occasionally get a read error on a container mid-rewrite; retrying is the documented workaround (see locking if that is unacceptable).
Store classes (blosc2.EmbedStore, blosc2.DictStore) are
stricter without locking: they are single-process, single-writer, since
their key maps are cached in Python and are not re-synced without a
sidecar lock. Reopen the store to see mutations made elsewhere.
Locking¶
Enable cross-process locking on a container by passing locking=True (in
blosc2.Storage, or directly to blosc2.SChunk,
blosc2.open(), or the array constructors), or by setting the
BLOSC_LOCKING environment variable, which enables it globally for every
on-disk container subsequently opened or created, without touching sources:
import os
import blosc2
# Per-handle
schunk = blosc2.SChunk(chunksize=1_000_000, urlpath="shared.b2frame", locking=True)
# Or fleet-wide, for a whole deployment
os.environ["BLOSC_LOCKING"] = "1"
With locking, readers take a shared lock and writers an exclusive one,
against a small sidecar lock file next to the container (.b2lock).
Mutating operations become atomic to other locked handles, and a handle
whose view went stale re-syncs before its next operation completes — closing
the read-error race SWMR-without-locking has.
SWMR vs. locking, side by side. examples/ndarray/swmr-enlarge-bars.py
and examples/ndarray/locking-enlarge-bars.py run the identical
one-writer/three-readers growth scenario, rendered with live throughput
bars, one unlocked and one locked, so the trade-off is visible instead of
just asserted:
The SWMR version’s readers need a “settled vs. just-resized” trailing trick to avoid reading uninitialized rows, and a retry loop around every read to absorb the occasional transient error from racing the writer’s in-progress metadata update (see “SWMR without locking” above for why even a “settled” region isn’t immune — resolving where a chunk lives on disk can itself race, independently of whether that chunk’s data is final). The locked version needs neither: wrapping each batch’s resize-plus-fill in
holding_lock()makes it atomic, so a locked reader’srefresh()only ever shows a batch fully there or not there at all — trust it immediately, no lag, no retries.The two versions also disagree on how a reader knows the writer is done. The SWMR version’s readers can’t infer that from shape alone: reaching the final expected shape only proves the last
resize()landed, not that its fill did — the same gap the “settled” trick guards every other batch against, just with no following batch left to trail behind. So it needs an explicit signal, and a variable-length metalayer (schunk.vlmeta) is the one piece of SWMR-visible state built for exactly this: unlike fixed metalayers (schunk.meta, outside the re-sync contract entirely), a vlmeta lookup re-syncs on every access, the same as a data read orrefresh(). The locked version needs no such flag:holding_lock()already made resize-plus-fill atomic, soshape == expected_rowsis proof the last batch is safe — see theverify_up_to/ completion-check difference between the two example readers.That safety isn’t free: the writer’s exclusive lock and the readers’ shared locks now genuinely serialize against each other, where the SWMR version has no coordination to wait on at all. On one dev machine, growing the same ~8 GB array with the same pacing measured ~2.1 GB/s combined throughput over 4.25s unlocked vs. ~1.9 GB/s over 4.63s locked — roughly a 10% cost here for eliminating the read-error race entirely. The exact number depends heavily on read/write pacing and how many readers are contending for the lock; measure your own workload rather than trusting this one.
The locking is advisory: it only protects a container if every handle that touches it enables it. A plain handle opened on a locked container bypasses the coordination entirely.
Caveats:
Not supported together with
mmap_mode: explicitlocking=Truewithmmap_moderaisesValueError. TheBLOSC_LOCKINGenvironment variable, being a global switch, is silently ignored for memory-mapped containers instead of raising.Not supported for in-memory containers (no
urlpath):locking=TrueraisesValueErrorthere too.Not supported on network filesystems (NFS) — the underlying
flock/LockFileExprimitives are unreliable there.Crash safety: locks are held via the OS (released automatically if a process dies while holding one), but a crash mid-mutation can still leave partial state in the data — see each store’s notes below.
Atomic multi-operation blocks with holding_lock()¶
Each locked operation locks and unlocks the container individually by
default, so a sequence of operations is not atomic as a whole — another
process could interleave a read or write between them. Use
SChunk.holding_lock() to hold the
exclusive lock across several operations, making the whole block atomic to
other handles:
with schunk.holding_lock():
schunk.update_data(0, data0, copy=True)
schunk.update_data(1, data1, copy=True)
Everything inside the block is serialized exclusively — including plain
reads through other locked handles — so keep it short. On a handle without
locking enabled, holding_lock() is a no-op.
SChunk.holding_lock() also refreshes this handle’s cached counters
(nchunks, nbytes, cbytes) right after
the lock is acquired, so a decision made inside the block from one of these
(e.g. if schunk.nchunks <= idx: ...) always sees the current on-disk
state, not a stale cache from before the lock was taken.
NDArray.holding_lock() delegates to the
same method on the underlying schunk for the locking itself, and additionally
refreshes this handle’s cached shape right
after the lock is acquired — so code that reads shape inside the block
(e.g. to decide a resize() target) always sees the current on-disk
state, not a stale cache from before the lock was taken.
holding_lock() is required for any read-modify-write across writers,
since a single indexed assignment reads the old value and writes the new one
as two separate locked operations:
# Two processes both doing `arr[i] += 1` need the increment itself
# locked, not just each half of it:
with arr.holding_lock():
arr[i] = arr[i] + 1
See examples/ndarray/mwmr-mode.py for this pattern run for real across
several processes, including a direct comparison of the wrong (unlocked)
and right (holding_lock()-wrapped) versions so you can see the lost
updates happen.
The same applies to NDArray.append(): it is
internally a refresh of the current length, a resize, and a slice write —
three steps, not one atomic operation. append() always refreshes its
cached length first, so when the whole call runs inside
holding_lock(), concurrent appends from other writers are picked up
correctly instead of being overwritten (each writer’s batch lands, in full,
somewhere in the final array — never lost, torn, or duplicated). Without
holding_lock(), the refresh does not help: another writer can still grow
the array between the refresh and the resize, and the same race applies as
any read-modify-write above.
# Every writer must wrap the whole append in holding_lock(), or a
# concurrent grower's data can be silently discarded:
with arr.holding_lock():
arr.append(new_rows)
See examples/ndarray/mwmr-enlarge.py for several processes appending
concurrently to the same array, then verifying every batch landed exactly
once with nothing lost, torn, or duplicated.
Per-operation atomicity: what counts as “one operation”¶
Without holding_lock(), two writers racing on overlapping regions
resolve last-writer-wins, at a granularity that depends on the API used:
SChunkchunk updates (SChunk.update_dataand friends) are atomic per chunk — an overlapping multi-chunk write from two writers can interleave chunk by chunk.NDArrayslice writes (arr[...] = value) are atomic for the whole slice, even when it spans several chunks — a locked reader never observes a half-applied slice write.
Fixed metalayers (schunk.meta) are outside the locking contract:
schunk.meta[name] = value from one handle is not visible to another
handle’s schunk.meta reads until that handle re-syncs some other way
(e.g. a data access). Use variable-length metalayers (schunk.vlmeta) if
cross-handle visibility of metadata updates matters — those poll for
staleness on every access.
The stores: cross-process guarantees¶
blosc2.EmbedStore and blosc2.DictStore build their
cross-process story on top of container locking:
Without locking: single-process, single-writer. Key maps are cached in Python and not re-synced, so mutations from another handle are invisible until reopen, and concurrent writers can corrupt each other’s entries.
With locking enabled on every handle: an on-disk store can be shared across processes. Each mutation (the data write plus the key-map update) runs under one exclusive lock, and every access re-syncs the key maps, so readers follow keys added or removed elsewhere.
Accepted races, even under locking:
EmbedStore: a crash between the data write and the map flush can leave unreachable bytes in the container. Harmless — reclaimed the next time the store is rewritten.
DictStore (directory-backed,
.b2d): a reader holding a value whose key another process just deleted may get errors reading that value afterwards. A crash mid-mutation can leave a partial external file behind.
.b2z archives need no locking at all: they are safe to share read-only
across any number of processes, and DictStore.to_b2z() (which also covers blosc2.TreeStore,
built on DictStore) replaces the target atomically — a temporary sibling
file is written and then moved onto the final path, so concurrent readers
always see either the old archive or the complete new one, never a torn
write. On Windows, the final replace fails if another process holds the
target file open.
Detecting mutation without re-reading data¶
SChunk.change_tick is a counter bumped
whenever a handle re-syncs from a stale on-disk state (whether via locking’s
generation counter or SWMR’s length poll). Compare it before and after an
operation to know cheaply whether another handle mutated the container in
between, without needing to re-read or diff the data — this is how the store
classes above detect that their cached key maps need a re-sync.
Summary¶
Mechanism |
Enable |
Guarantees |
|---|---|---|
SWMR (default) |
always on |
single writer, readers follow shape/length growth on next access |
Locking |
|
multiple concurrent writers, atomic ops, no torn reads |
|
context manager on a locked handle |
atomic multi-operation blocks |
|
always safe to share read-only, atomic replace on write |
Not supported in either mechanism: network filesystems (NFS). Locking
additionally excludes mmap_mode and in-memory containers.