Releasing the GIL during coordinate transforms
This page answers one question: how do you release the Global Interpreter Lock around a long-running PROJ coordinate transform so a multi-second reprojection does not freeze every other thread in the interpreter — without corrupting memory or touching a PyObject while the lock is dropped? It sits inside the Memory Management in Geospatial Extensions section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the exact Py_BEGIN_ALLOW_THREADS placement, the buffer-copy discipline it demands, and a benchmark that proves the parallel speedup.
Context & Root Cause
CPython’s Global Interpreter Lock serializes execution of Python bytecode: only one thread runs interpreter code at a time. That is fine until a C-extension enters a long native call — a PROJ pipeline transforming a million coordinate pairs takes hundreds of milliseconds to seconds, and for that whole span the thread holds the GIL while doing zero Python work. Every other thread, including asyncio’s event loop and any parallel workers, is frozen behind it. The reprojection is embarrassingly parallel at the C level, yet the process behaves as if single-threaded.
The fix is to bracket the pure-native work with Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS, which drop and reacquire the lock. The constraint that makes this a memory-management problem, not just a performance tweak, is absolute: while the GIL is released you may not touch any PyObject — no reference counting, no PyList_GetItem, no buffer object access — because another thread may be mutating interpreter state concurrently. You must therefore copy the coordinate data out of Python objects into a raw C buffer before releasing, and build the result after reacquiring. Get the ordering wrong and you get a race that manifests as a Segmentation fault under load, exactly the class of crash catalogued in Memory Management in Geospatial Extensions.
Solution / Fix
This targets CPython 3.9–3.12 and PROJ 9.2+ (proj_trans_array from the proj.h C API). The pattern holds whether you write raw C or Cython.
1. Copy into a native buffer under the lock
// Still holding the GIL: validate and extract into a C-owned buffer.
Py_buffer view;
if (PyObject_GetBuffer(coords_obj, &view, PyBUF_C_CONTIGUOUS | PyBUF_FORMAT) < 0)
return NULL; // borrows the Python buffer
size_t n = view.len / (2 * sizeof(double));
double *xy = PyMem_RawMalloc(view.len); // RawMalloc: safe without the GIL
if (!xy) { PyBuffer_Release(&view); return PyErr_NoMemory(); }
memcpy(xy, view.buf, view.len);
PyBuffer_Release(&view); // done touching Python memory
PyMem_RawMalloc is deliberate: the standard PyMem_Malloc requires the GIL, so a buffer you intend to use after releasing the lock must come from the raw allocator, as Memory Management in Geospatial Extensions details.
2. Release, transform, reacquire
PJ *P = /* thread-safe PJ* cloned per call via proj_clone on a shared context */;
int ok;
Py_BEGIN_ALLOW_THREADS // ← GIL dropped here
ok = proj_trans_array(P, PJ_FWD, n, (PJ_COORD *)xy);
Py_END_ALLOW_THREADS // ← GIL reacquired here
// Only now is it legal to touch Python objects again.
if (ok != 0) {
PyMem_RawFree(xy);
PyErr_SetString(PyExc_RuntimeError, "proj_trans_array failed");
return NULL;
}
The PJ object must be safe to use off-thread. PROJ contexts (PJ_CONTEXT) are not thread-safe to share, so clone the transform onto a per-thread context with proj_clone before entering the released window, or create one context per worker thread.
3. Build the result after reacquiring
PyObject *out = PyByteArray_FromStringAndSize((char *)xy, n * 2 * sizeof(double));
PyMem_RawFree(xy);
return out; // ownership handed to the interpreter
Verification
# 1. Prove the parallel speedup with a threaded benchmark
python - <<'PY'
import threading, time
from _geospatial_ext import transform_bulk # the extension above
data = [b"\x00" * (2*8*1_000_000)] # 1M coord pairs
def work(): transform_bulk(data[0])
t0 = time.perf_counter()
ts = [threading.Thread(target=work) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(f"4 threads: {time.perf_counter()-t0:.2f}s")
PY
# expected: close to 1x the single-call time, not 4x (threads overlap)
# 2. Prove no memory corruption under contention (ASan build)
PYTHONMALLOC=malloc python -X dev -c "import _geospatial_ext; print('ok')"
# expected: ok, with no ASan heap-use-after-free or data-race report
If the four-thread time is ~4× a single call, the GIL was never released — confirm the Py_BEGIN_ALLOW_THREADS macro actually wraps the native call and is not short-circuited by an early return.
Pitfalls & Alternatives
Touching a PyObject inside the released window. Even a stray Py_DECREF or PyList_SET_ITEM between the macros is a data race. Keep the block purely native; if you need Python data, copy it out first. This is the number-one cause of intermittent Segmentation fault (core dumped) in threaded geospatial code.
Sharing one PJ_CONTEXT across threads. PROJ contexts carry mutable error state and a grid cache; concurrent use corrupts both. Clone per thread with proj_clone, or gate context creation per worker.
Releasing the GIL for trivial work. The macros have a real cost (lock handoff). For a transform of a few points the overhead dominates; only release around work measured in milliseconds or more. For the broader allocator and teardown rules these buffers rely on, see fixing memory leaks in GDAL Python bindings.
The Window Where Python Does Not Exist
The discipline this pattern demands is easier to hold if you picture the released window as a period during which the interpreter, from your code’s point of view, has been switched off. Nothing that touches a PyObject is legal — not a reference count, not an exception set, not a buffer view, not a logging call that happens to go through Python. Everything the transform needs must have been copied into plain C memory before the window opens, and everything it produces must stay in plain C memory until the window closes.
The error-handling rule in the last line is the one that catches people out, because failure is exactly when the instinct to raise immediately is strongest. The correct shape is to record the failure in a plain C variable inside the window, close the window, and only then translate it into a Python exception. PROJ makes this straightforward: proj_trans_array returns a non-zero count of failed coordinates, and proj_errno can be read afterwards for the specific code.
Thread safety of the transform object itself is the other half of the contract. A PJ_CONTEXT must not be shared across threads, and a PJ created on one context must not be used from another. The workable patterns are one context per worker thread, created once and reused, or proj_clone onto a thread-local context at the start of each call. Sharing a single transformer across a thread pool produces intermittent wrong results and occasional crashes — a failure that reproduces only under concurrency and therefore only in production.
What Four Threads Actually Do
The payoff is easiest to see as a timeline of four worker threads transforming equal slices of the same array. With the lock held throughout, the native work serialises even though it touches no interpreter state; with it released around the transform, the four native segments overlap and only the short copy-in and build-out phases contend.
The grey segments are the reason the speedup is never exactly linear: copying a million coordinate pairs into a raw buffer is real work, and it still serialises. Making those segments as small as possible — by accepting a contiguous buffer that can be copied with a single memcpy rather than iterated element by element — is what turns a two-times speedup into something closer to the core count.
When Releasing the Lock Does Not Pay
Releasing the GIL is not free: the pair of macros costs a lock handoff and, for a short call, that cost dominates. There is a rough threshold below which the pattern makes the code slower and more fragile for no benefit, and it is worth knowing roughly where it sits.
Measured on ordinary hardware, the release-and-reacquire pair costs on the order of a microsecond when uncontended, and considerably more when several threads are competing to reacquire. A PROJ transform of a single coordinate pair takes a few microseconds; a batch of a thousand takes tens of microseconds; a batch of a million takes hundreds of milliseconds. The pattern therefore pays overwhelmingly for bulk array transforms, marginally for batches in the low thousands, and not at all for per-point calls.
That has a design implication beyond the C code: the binding’s API shape determines whether the optimisation is available at all. An interface that transforms one point per call can never benefit, because the per-call overhead is the entire cost. An interface that accepts an array — a NumPy array, a buffer, a memoryview — can copy once, release once, and transform a million points in a single window. If you are designing the extension rather than maintaining one, the array-shaped API is the decision that makes everything else possible.
/* Below a threshold the released window costs more than it saves. */
if (n < 4096) {
ok = proj_trans_array(P, PJ_FWD, n, (PJ_COORD *)xy); /* keep the GIL */
} else {
Py_BEGIN_ALLOW_THREADS
ok = proj_trans_array(P, PJ_FWD, n, (PJ_COORD *)xy);
Py_END_ALLOW_THREADS
}
Finally, note what the pattern does not buy you. Releasing the GIL lets other Python threads run; it does not parallelise the transform itself. Using several cores for one large reprojection means splitting the array across threads, each with its own context and its own released window — at which point the copy discipline matters even more, since several windows are open at once over disjoint slices of the same buffer.
Frequently Asked Questions
Does this still matter on free-threaded CPython?
The mechanics change and the discipline does not. On a build without the GIL there is no lock to release, so the macros become no-ops, but every rule about not touching interpreter state from a thread that has not attached remains — and new hazards appear, because objects genuinely can be mutated concurrently. Extensions that already separate a pure-C computation window from their Python-facing code are the ones that port most easily.
Can I use Cython instead of writing this by hand?
Yes, and for most projects you should. Cython’s with nogil: block generates the same macro pair, and its typed memoryviews give you a checked way to access buffer data without holding references to Python objects. The rules are identical — no Python objects inside the block — but the compiler enforces a good share of them, which removes the most common source of mistakes.
How do I know the lock was really released?
Measure it rather than assume it. Run the same workload in one thread and in four; if wall-clock time is flat, the lock is still held somewhere in the hot path. A common cause is a logging or callback hook inside the loop that re-enters Python, which quietly reacquires and serialises everything again.
What about GDAL — can I release the GIL around its calls too?
For most raster I/O, yes, and the bindings already do it in several places. The caveats are that GDAL’s error handler is process-global and may call back into Python if one has been installed, and that a Dataset must not be used concurrently from multiple threads unless it was opened with the thread-safe access mode. Sharing datasets across a pool is the single most common cause of hard-to-reproduce crashes in threaded GDAL code.
Related
- Memory Management in Geospatial Extensions — the parent guide on native heap ownership, allocator choice, and teardown order.
- Fixing memory leaks in GDAL Python bindings — the dataset-lifecycle discipline that governs the buffers this pattern copies.
- C-API vs CPython ABI compatibility — why
PyMem_RawMallocand the thread macros are all inside the Stable ABI.