Visitar URL original
Python 3.14 free-threaded support by greateggsgreg · Pull Request #2721 · pythonnet/pythonnet · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1d93b30
Thread-safety prep for free-threading builds
greateggsgreg May 9, 2026
e029137
Initialise pythonnet on free-threaded Python (#2720)
greateggsgreg May 9, 2026
fd56aa4
Make extension/CLR-object registries thread-safe
greateggsgreg May 9, 2026
b727a15
Atomic type creation in ReflectedClrType.GetOrCreate / TypeManager.Ge…
greateggsgreg May 9, 2026
debba0d
Add free-threaded thread-stress tests and 3.14t to CI matrix
greateggsgreg May 9, 2026
585916e
Atomic GCHandle ownership and finalizer-thread shutdown guards
greateggsgreg May 10, 2026
c0ddb29
Make additional internal registries thread-safe
greateggsgreg May 10, 2026
096e466
test_thread: join worker threads before returning
greateggsgreg May 10, 2026
5005f36
test_thread: cover ModuleObject thread-safe registries
greateggsgreg May 10, 2026
3ad586a
Wider thread-safety audit fixes for free-threaded Python
greateggsgreg May 10, 2026
6b1f3f4
Document lock acquisition sites and strong->weak GCHandle swap
greateggsgreg May 10, 2026
732d97e
Preserve InternString single-write invariant under DEBUG
greateggsgreg May 10, 2026
3d56611
test_thread: cover real-world consumer patterns
greateggsgreg May 10, 2026
7094b12
Auto-detect free-threaded libpython in venv home
greateggsgreg May 12, 2026
f49ed1b
Snapshot pypath, use ConcurrentDictionary for thunks and slot holders
greateggsgreg May 13, 2026
fa89ff0
Fix handling of python runtime suffixes m/t
greateggsgreg May 13, 2026
a373e79
Fix threadtest race
greateggsgreg May 13, 2026
43a4237
Fix double-free in chained ClassDerived Finalize
greateggsgreg May 13, 2026
b42377e
Enable Mono CI jobs on free-threaded Python 3.14
greateggsgreg May 13, 2026
526a297
Inline freethreaded_only as pytest.mark.skipif at call sites
greateggsgreg May 13, 2026
8485837
Fix InterruptTest assertion on free-threaded Python 3.14
greateggsgreg May 13, 2026
a459772
Add concurrent stress tests for PyBuffer.Dispose and CLR-cycle gc.col…
greateggsgreg May 15, 2026
3bf6d68
Trim concurrent overhead on hot paths from free-threading prep
greateggsgreg May 15, 2026
3433446
Pre-warm ctor binder in concurrent-gc test to avoid first-call race
greateggsgreg May 15, 2026
4a034f7
Make MethodBinder.GetMethods lazy init thread-safe under free-threading
greateggsgreg May 15, 2026
afd5799
Precompute method precedence to avoid quadratic GetParameters allocat…
greateggsgreg May 15, 2026
95e4811
Zero the slot in ClassDerived.tp_dealloc when tp_clear already ran to…
greateggsgreg May 15, 2026
a799c44
Keep ClassDerived wrapper alive across the NewObjectToPython slot dem…
greateggsgreg May 15, 2026
220b4b4
Document private helpers added during free-threading prep
greateggsgreg May 16, 2026
dfda807
Add debug echoes and a 6-minute step timeout to the Mono test job
greateggsgreg May 16, 2026
1130f6a
Drop the per-loop CLR GC.Collect from concurrent-gc test to avoid Mon…
greateggsgreg May 16, 2026
f75cbab
Add temporary Mono-step diagnostics on Linux/macOS to locate the x64-…
greateggsgreg May 16, 2026
64ea342
Revert temporary Mono-step diagnostics now that the underlying race i…
greateggsgreg May 16, 2026
fda8211
Add user-facing threading guide covering GIL, free-threading, and com…
greateggsgreg May 18, 2026
356d76f
Harden CollectBasicObject against .NET-GC timing differences
greateggsgreg May 18, 2026
f0ee4b2
Adjust the header offset on 32bit systems
filmor Aug 21, 2026
3445b08
Drop broken and unnecessary exclude
filmor Aug 22, 2026
8e6861e
Be strict about not loading on Python 3.13
filmor Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Atomic GCHandle ownership and finalizer-thread shutdown guards
Two related sets of races that the GIL hid but free-threaded Python
exposes reliably.

ClassDerivedObject.tp_dealloc and ManagedType.TryFreeGCHandle both
read the GCHandle slot, then mutated it.  Under FT, subtype_clear (on
the main thread) and the .NET finalizer thread can race for the same
slot; a non-atomic read-then-zero lets both threads observe the same
handle and double-free it.  Both paths now use Interlocked.Exchange
to atomically claim ownership of the slot - only the thread that
observes a non-zero handle frees it.  ClassDerived's strong-to-weak
swap follows the same pattern.

CreateDerivedType emits IL via Reflection.Emit, whose ModuleBuilder
and TypeBuilder operations are documented as not thread-safe.
Concurrent dynamic subclass creation under FT corrupts the IL stream
and segfaults.  Serialise the entire emit-and-bake sequence on the
existing _buildersLock (the lock that already guarded the assembly /
module builder cache).

The .NET finalizer thread can dispatch Py_DecRef calls concurrently
with Py_Finalize, and a stale read of ob_ref_local after teardown
crashes the process.  Three guards:

- Finalizer.ThrottledCollect and PyObject's finalizer short-circuit
  when Runtime._Py_IsFinalizing(); PyObject drops the raw pointer
  instead of enqueueing a decref so process exit reclaims the memory.
- Finalizer.AddFinalizedObject's Refcount > 0 Debug.Assert is kept on
  GIL builds and skipped on FT; a stale ob_ref_local read from the
  finalizer thread can crash the process even when the assertion
  would succeed under the GIL.
- Runtime.XDecref's matching Refcount > 0 Debug.Assert gets the same
  FT-only skip for the same reason.
  • Loading branch information
greateggsgreg authored and filmor committed Aug 18, 2026
commit 585916ef447e809b19847fc8f111479c388113ac
12 changes: 11 additions & 1 deletion src/runtime/Finalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ internal void ThrottledCollect()

_throttled = unchecked(this._throttled + 1);
if (!started || !Enable || _throttled < Threshold) return;
// Skip the drain while Python is finalizing: the .NET-side queue may
// contain references that became stale during teardown, and calling
// Py_DecRef on torn-down state segfaults under free-threaded Python.
if (Runtime._Py_IsFinalizing() == true) return;
_throttled = 0;
this.Collect();
}
Expand All @@ -136,7 +140,13 @@ internal void AddFinalizedObject(ref IntPtr obj, int run
return;
}

Debug.Assert(Runtime.Refcount(new BorrowedReference(obj)) > 0);
// Skip the Refcount sanity check under free-threaded Python: the .NET
// finalizer thread can race with Py_Finalize and a stale read of
// ob_ref_local will crash the process. Keep the check on GIL builds.
if (!Native.ABI.IsFreeThreaded)
{
Debug.Assert(Runtime.Refcount(new BorrowedReference(obj)) > 0);
}

#if FINALIZER_CHECK
lock (_queueLock)
Expand Down
19 changes: 15 additions & 4 deletions src/runtime/PythonTypes/PyObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,24 @@ internal PyObject(in StolenReference reference)
CheckRun();
#endif

Interlocked.Increment(ref Runtime._collected);
// If Python is finalizing we cannot safely enqueue a decref:
// the .NET finalizer thread runs concurrently with Py_Finalize
// and any later Py_DecRef would touch torn-down state. Drop
// the reference and let process exit reclaim the memory.
if (Runtime._Py_IsFinalizing() == true)
{
rawPtr = IntPtr.Zero;
}
else
{
Interlocked.Increment(ref Runtime._collected);

Finalizer.Instance.AddFinalizedObject(ref rawPtr, run
Finalizer.Instance.AddFinalizedObject(ref rawPtr, run
#if TRACE_ALLOC
, Traceback
, Traceback
#endif
);
);
}
}

Dispose(false);
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/Runtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,10 @@ internal static unsafe void XIncref(BorrowedReference op)
internal static unsafe void XDecref(StolenReference op)
{
#if DEBUG
Debug.Assert(op == null || Refcount(new BorrowedReference(op.Pointer)) > 0);
// The Refcount > 0 check is racy under free-threaded Python: the .NET
// finalizer thread can dispatch decrefs concurrently with Py_Finalize,
// and a stale read of ob_ref_local can crash the process. Skip on FT.
Debug.Assert(op == null || Native.ABI.IsFreeThreaded || Refcount(new BorrowedReference(op.Pointer)) > 0);
Debug.Assert(_isInitialized || Py_IsInitialized() != 0 || _Py_IsFinalizing() != false);
#endif
if (op == null) return;
Expand Down
47 changes: 38 additions & 9 deletions src/runtime/Types/ClassDerived.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ protected override void SetTypeNewSlot(BorrowedReference pyType, SlotsHolder slo
// Python derived types rely on base tp_new and overridden __init__
}

public new static void tp_dealloc(NewReference ob)
public new static unsafe void tp_dealloc(NewReference ob)
{
var self = (CLRObject?)GetManagedObject(ob.Borrow());

Expand All @@ -84,14 +84,26 @@ protected override void SetTypeNewSlot(BorrowedReference pyType, SlotsHolder slo
// self may be null after Shutdown begun
if (self is not null)
{
// The python should now have a ref count of 0, but we don't actually want to
// deallocate the object until the C# object that references it is destroyed.
// So we don't call PyObject_GC_Del here and instead we set the python
// reference to a weak reference so that the C# object can be collected.
GCHandle oldHandle = GetGCHandle(ob.Borrow());
GCHandle gc = GCHandle.Alloc(self, GCHandleType.Weak);
SetGCHandle(ob.Borrow(), gc);
oldHandle.Free();
// Replace the strong handle with a weak one so the C# wrapper can be
// collected, but the Python object survives until it does.
//
// Under free-threaded Python a concurrent tp_dealloc/tp_clear path
// could race with us; do the swap atomically and only free the old
// handle if we were the thread that observed it.
GCHandle weak = GCHandle.Alloc(self, GCHandleType.Weak);
BorrowedReference borrow = ob.Borrow();
int offset = Util.ReadInt32(Runtime.PyObject_TYPE(borrow), Offsets.tp_clr_inst_offset);
IntPtr* slot = (IntPtr*)(borrow.DangerousGetAddress() + offset);
IntPtr oldRaw = System.Threading.Interlocked.Exchange(ref *slot, (IntPtr)weak);
if (oldRaw != IntPtr.Zero)
{
((GCHandle)oldRaw).Free();
}
else
{
// Lost the race; another thread already cleared the slot.
weak.Free();
}
}
}

Expand Down Expand Up @@ -166,6 +178,23 @@ internal static Type CreateDerivedType(string name,
assemblyName = "Python.Runtime.Dynamic";
}

// Reflection.Emit's ModuleBuilder/TypeBuilder operations are not
// thread-safe; concurrent DefineType calls on the same module
// corrupt the IL stream and segfault under free-threaded Python.
// Serialise the entire emit-and-bake sequence on _buildersLock.
lock (_buildersLock)
{
return CreateDerivedTypeImpl(name, baseType, typeInterfaces, py_dict, assemblyName, moduleName);
}
}

private static Type CreateDerivedTypeImpl(string name,
Type baseType,
IList<Type> typeInterfaces,
BorrowedReference py_dict,
string assemblyName,
string moduleName)
{
ModuleBuilder moduleBuilder = GetModuleBuilder(assemblyName, moduleName);

Type baseClass = baseType;
Expand Down
14 changes: 8 additions & 6 deletions src/runtime/Types/ManagedType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ internal static void SetGCHandle(BorrowedReference reflectedClrObject, GCHandle
internal static bool TryFreeGCHandle(BorrowedReference reflectedClrObject)
=> TryFreeGCHandle(reflectedClrObject, Runtime.PyObject_TYPE(reflectedClrObject));

internal static bool TryFreeGCHandle(BorrowedReference reflectedClrObject, BorrowedReference type)
internal static unsafe bool TryFreeGCHandle(BorrowedReference reflectedClrObject, BorrowedReference type)
{
Debug.Assert(type != null);
Debug.Assert(reflectedClrObject != null);
Expand All @@ -240,13 +240,15 @@ internal static bool TryFreeGCHandle(BorrowedReference reflectedClrObject, Borro
int offset = Util.ReadInt32(type, Offsets.tp_clr_inst_offset);
Debug.Assert(offset > 0);

IntPtr raw = Util.ReadIntPtr(reflectedClrObject, offset);
// Atomic claim of the GCHandle: under free-threaded Python tp_clear
// and tp_dealloc can race against each other (e.g. main thread vs
// .NET finalizer thread). A non-atomic read-then-zero would let
// both threads see the same handle and double-free it.
IntPtr* slot = (IntPtr*)(reflectedClrObject.DangerousGetAddress() + offset);
IntPtr raw = System.Threading.Interlocked.Exchange(ref *slot, IntPtr.Zero);
if (raw == IntPtr.Zero) return false;

var handle = (GCHandle)raw;
handle.Free();

Util.WriteIntPtr(reflectedClrObject, offset, IntPtr.Zero);
((GCHandle)raw).Free();
return true;
}

Expand Down