You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Is lazy type realization intended to be thread-safe on GIL builds (3.11)? #271
Context: we saw repeated SIGSEGVs in a threaded ASGI service on 3.11 that correlated with a transitive bump to cffi 2.0.0, and they stopped when we pinned back to 1.17.1. I'd like to know whether GIL builds are supposed to be safe here, so I know whether to keep looking elsewhere for our root cause.
Disclaimer: The below is written by AI. If that frustrates you, feel free to close. If not, thank you for reading on 🙏
Since 2.0.0 the type-realization paths carry explicit thread-safety machinery, but as far as I can tell all of it is compiled only for free-threaded builds. On a GIL build (I'm on CPython 3.11):
CFFI_LOCK() / CFFI_UNLOCK() expand to Py_BEGIN/END_CRITICAL_SECTION, and misc_thread_common.h redefines those to a bare { / } for PY_VERSION_HEX <= 0x030d00b3. So on 3.11 the lock is an empty scope.
The publish in _realize_c_struct_or_union is cffi_atomic_store(&builder->ctx.types[s->type_index], x) under Py_GIL_DISABLED, and a plain store otherwise.
The primitive pre-initialization loop in init_global_types_dict — the one commented "Ensure that all primitive types are initialised to avoid race conditions on the first access" — is itself inside #ifdef Py_GIL_DISABLED.
My question is simply whether that asymmetry is deliberate: is the GIL considered sufficient to serialize first-time type realization, so that no additional locking is needed on non-free-threaded builds?
The reason I'm unsure is that realization doesn't look like a single uninterruptible C step. do_realize_lazy_struct / complete_struct_or_union allocate, and in the s->size == (size_t)-2 branch the "unrealized" flag is cleared and the type published beforedo_realize_lazy_struct(ct) runs:
if (ct!=NULL) {
cffi_set_flag(ct->ct_unrealized_struct_or_union, 0);
}
...
builder->ctx.types[s->type_index] =x;
if (ct!=NULL&&s->size== (size_t)-2) {
if (do_realize_lazy_struct(ct) <0) { ... }
}
If a thread switch can land in that window, it looks like another thread could observe a type marked realized but not yet completed. I may well be misreading the invariant — if allocation there can't drop the GIL in a way that matters, that would explain it.
It is not thread safe on GIL enabled builds, the atomics are only used on free-threading so there are no fences on gil enabled builds to protect against concurrent realization.
I took another look at this issue. The problem is actually that on Python 3.11 and older, allocating a GC-tracked object can trigger GC before the allocation returns. This behavior makes lots more things possibly re-entrant and is the likely source of the crash the reporter ran into, although we still don't have a reproducer.
3.12 and newer merely schedules a GC pass, so the same operations are no longer re-entrant.
Just making the GIL-enabled build use atomic operations like the free-threaded build does won't fix that.
It avoids the crash by toggling the GC, which avoids the possibility of reentrancy I described above. It also adds a test for this scenario that runs on Python 3.11 and older. Could you see if that fixes the crash you ran into? I'd also appreciate it if you could check for if this introduces memory spikes or other downsides.
Activity
mattip commentedon Sep 8, 2026
@ngoldbaum thoughts?
ngoldbaum commentedon Sep 8, 2026
Critical sections are supposed to provide exactly as much safety as the GIL provides.
Can you by any chance make a runnable minimal example that demonstrates the crash you're running into?
ngoldbaum commentedon Sep 8, 2026
also ping @kumaraditya303
kumaraditya303 commentedon Sep 9, 2026
It is not thread safe on GIL enabled builds, the atomics are only used on free-threading so there are no fences on gil enabled builds to protect against concurrent realization.
ngoldbaum commentedon Sep 9, 2026
Hmm, well we should probably fix that!
ngoldbaum commentedon Sep 11, 2026
I took another look at this issue. The problem is actually that on Python 3.11 and older, allocating a GC-tracked object can trigger GC before the allocation returns. This behavior makes lots more things possibly re-entrant and is the likely source of the crash the reporter ran into, although we still don't have a reproducer.
3.12 and newer merely schedules a GC pass, so the same operations are no longer re-entrant.
Just making the GIL-enabled build use atomic operations like the free-threaded build does won't fix that.
ngoldbaum commentedon Sep 14, 2026
@calderwoodra I have a branch that I think avoids the crash you ran into:
main...ngoldbaum:cffi-ft:toggle-gc
It avoids the crash by toggling the GC, which avoids the possibility of reentrancy I described above. It also adds a test for this scenario that runs on Python 3.11 and older. Could you see if that fixes the crash you ran into? I'd also appreciate it if you could check for if this introduces memory spikes or other downsides.
mattip commentedon Sep 22, 2026
@calderwoodra did you get a chance to check the crash?