Fix two NDArray thread-safety bugs blocking free-threaded support (#555) - #713
Conversation
get_1d_span_numpy borrowed the SChunk's shared dctx when non-NULL, but a Blosc2 decompression context is mutable. Concurrent calls from multiple threads (as done by the indexing query planner) can corrupt each other's decoded output. Always create a private per-call context instead, still associated with the SChunk via dparams.schunk so codecs/filters that resolve per-schunk state during decompression keep working.
b2nd_get_slice_cbuffer has no per-call context parameter, so concurrent reads against the same NDArray corrupt shared native state and segfault. Add a per-instance lock around this call; independent NDArray objects are unaffected and still read in parallel.
4ee350e to
1efb917
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The lock must cover aliased NDArray wrappers, and private contexts must preserve configured SChunk decompression parameters.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This PR addresses two NDArray read-path thread-safety issues affecting free-threaded Python.
Changes:
- Adds locking around slice reads.
- Creates private decompression contexts for span reads.
- Handles lock allocation and cleanup.
| File | Summary |
|---|---|
src/blosc2/blosc2_ext.pyx |
Adds NDArray read synchronization and private decompression contexts. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Addressed both Copilot findings in 4a88750 and replied in the two review threads. Local validation passed: 123 adjacent NDArray/CTable tests on regular CPython and again with the GIL disabled on CPython 3.14 free-threaded, plus Ruff. The GitHub Actions workflows for this new fork commit are currently marked |
|
Thanks @Johnny-Kao ! Your PR looks pretty well to me. My AI agent is suggesting this: """
The good parts are preserving the SChunk’s decompression parameters, giving span reads private contexts, and sharing the lock across views while retaining the base’s lifetime. I’d also benchmark repeated small span reads: creating a decompression context per call is correct in principle, but could add noticeable indexing overhead. |
|
Thanks for the careful review — I have addressed the GIL-deadlock concern by acquiring the shared NDArray read lock inside I also considered removing the shared lock to improve parallel slice-read throughput. However, |
|
Thank you @Johnny-Kao ! |

Summary
While investigating #555 (free-threaded/no-GIL support), I found and fixed two
thread-safety bugs in
NDArray's array-level read paths that cause datacorruption or a crash under
-X gil=0. These are narrow correctness fixes,not the full free-threading enablement sketched in #555 (setting
RELEASEGIL) — in fact, testing showed that flippingRELEASEGILalone,without these two fixes, would surface exactly this kind of corruption/crash.
Commits
1.
get_1d_span_numpy: stop sharing the SChunk's mutable dctx across threadsThis method (used internally by the indexing query planner) borrowed
self.array.sc.dctxwhen non-NULL. That context is mutable state;concurrent decompress calls from multiple threads can corrupt each other's
output. Changed it to always create a private per-call
dctx. To avoidregressing on schunks whose decompression depends on per-schunk state (e.g.
dictionaries resolved through
dparams.schunk), the private context isstill associated with the SChunk rather than built from bare
BLOSC2_DPARAMS_DEFAULTS.2.
get_slice_numpy: add a per-NDArray lock around the array-level readb2nd_get_slice_cbufferhas no context parameter to make it thread-local,and concurrent calls against the same
NDArraysegfault. Added aper-instance
PyThread_type_lock, acquired only around this call.Independent
NDArrayobjects still read fully in parallel; only concurrentreads on the same object serialize.
Test plan
(3x) on a free-threaded CPython 3.14t build under
-X gil=0.build under
-X gil=0, with no new failures relative to unpatchedmainon the same interpreters.Notes
-X gil=0(a segfault inSChunk.__setitem__'s write path, unrelated tothese two read-path fixes). I confirmed that crash reproduces on unpatched
mainas well, so it's a separate, pre-existing gap — out of scope here.I've reviewed, tested, and can explain every change above.