Skip to content

Commit c1a9aaa

Browse files
committed
Initialise pythonnet on free-threaded Python (#2720)
Free-threaded CPython (Py_GIL_DISABLED) changes the PyObject layout in two pythonnet-relevant ways: - The header is 16 bytes larger (ob_tid + flags + mutex + gc_bits + ob_ref_local + ob_ref_shared replace the single ob_refcnt). - The refcount is no longer a single field; reads must go through the Py_REFCNT API. Detect the build at runtime via sys._is_gil_enabled() in ABI.Initialize and: - Set ObjectHeadOffset to 16 on free-threaded builds so the generated TypeOffset values still resolve to absolute PyHeapTypeObject offsets. - Skip the ob_refcnt probe (it scans for an IntPtr value of 1 which cannot be located reliably under the FT layout). Add a Py_REFCNT P/Invoke (try-loaded; only exported as a function on CPython 3.14+) and prefer it in Runtime.Refcount, falling back to the existing direct read on older Pythons that only expose Py_REFCNT as a macro.
1 parent 44d14a7 commit c1a9aaa

3 files changed

Lines changed: 46 additions & 11 deletions

File tree

‎src/runtime/Native/ABI.cs‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@ namespace Python.Runtime.Native
77

88
static class ABI
99
{
10-
public static int RefCountOffset { get; } = GetRefCountOffset();
11-
public static int ObjectHeadOffset => RefCountOffset;
10+
// Offset of ob_refcnt within PyObject. GIL builds only; on free-threaded
11+
// Python the refcount is split (ob_ref_local + ob_ref_shared) and Refcount
12+
// uses Py_REFCNT instead.
13+
public static int RefCountOffset { get; private set; }
14+
15+
// Bytes to add to generated TypeOffset values for absolute PyHeapTypeObject
16+
// offsets. Free-threaded PyObject_HEAD is 16 bytes larger than the GIL one.
17+
public static int ObjectHeadOffset { get; private set; }
18+
19+
public static bool IsFreeThreaded { get; private set; }
1220

1321
internal static void Initialize(Version version)
1422
{
23+
IsFreeThreaded = DetectFreeThreaded();
24+
RefCountOffset = IsFreeThreaded ? -1 : ProbeRefCountOffset();
25+
ObjectHeadOffset = IsFreeThreaded ? 16 : RefCountOffset;
26+
1527
string offsetsClassSuffix = string.Format(CultureInfo.InvariantCulture,
1628
"{0}{1}", version.Major, version.Minor);
1729

@@ -34,7 +46,20 @@ internal static void Initialize(Version version)
3446
TypeOffset.Use(typeOffsets, nativeOffsetsClass == null ? ObjectHeadOffset : 0);
3547
}
3648

37-
static unsafe int GetRefCountOffset()
49+
static bool DetectFreeThreaded()
50+
{
51+
// sys._is_gil_enabled() was added in Python 3.13; absent means GIL build.
52+
using var sys = Runtime.PyImport_ImportModule("sys");
53+
if (sys.IsNull()) { Runtime.PyErr_Clear(); return false; }
54+
using var func = Runtime.PyObject_GetAttrString(sys.Borrow(), "_is_gil_enabled");
55+
if (func.IsNull()) { Runtime.PyErr_Clear(); return false; }
56+
using var args = Runtime.PyTuple_New(0);
57+
using var result = Runtime.PyObject_Call(func.Borrow(), args.Borrow(), default);
58+
if (result.IsNull()) { Runtime.PyErr_Clear(); return false; }
59+
return Runtime.PyObject_IsTrue(result.Borrow()) == 0;
60+
}
61+
62+
static unsafe int ProbeRefCountOffset()
3863
{
3964
using var tempObject = Runtime.PyList_New(0);
4065
IntPtr* tempPtr = (IntPtr*)tempObject.DangerousGetAddress();

‎src/runtime/Runtime.Delegates.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ static Delegates()
1515
{
1616
Py_IncRef = (delegate* unmanaged[Cdecl]<BorrowedReference, void>)GetFunctionByName(nameof(Py_IncRef), GetUnmanagedDll(_PythonDll));
1717
Py_DecRef = (delegate* unmanaged[Cdecl]<StolenReference, void>)GetFunctionByName(nameof(Py_DecRef), GetUnmanagedDll(_PythonDll));
18+
try
19+
{
20+
// Exported as a function only on CPython 3.14+; required for free-threaded.
21+
Py_REFCNT = (delegate* unmanaged[Cdecl]<BorrowedReference, nint>)GetFunctionByName(nameof(Py_REFCNT), GetUnmanagedDll(_PythonDll));
22+
}
23+
catch (MissingMethodException)
24+
{
25+
Py_REFCNT = null;
26+
}
1827
Py_Initialize = (delegate* unmanaged[Cdecl]<void>)GetFunctionByName(nameof(Py_Initialize), GetUnmanagedDll(_PythonDll));
1928
Py_InitializeEx = (delegate* unmanaged[Cdecl]<int, void>)GetFunctionByName(nameof(Py_InitializeEx), GetUnmanagedDll(_PythonDll));
2029
Py_IsInitialized = (delegate* unmanaged[Cdecl]<int>)GetFunctionByName(nameof(Py_IsInitialized), GetUnmanagedDll(_PythonDll));
@@ -316,6 +325,7 @@ static Delegates()
316325

317326
internal static delegate* unmanaged[Cdecl]<BorrowedReference, void> Py_IncRef { get; }
318327
internal static delegate* unmanaged[Cdecl]<StolenReference, void> Py_DecRef { get; }
328+
internal static delegate* unmanaged[Cdecl]<BorrowedReference, nint> Py_REFCNT { get; }
319329
internal static delegate* unmanaged[Cdecl]<void> Py_Initialize { get; }
320330
internal static delegate* unmanaged[Cdecl]<int, void> Py_InitializeEx { get; }
321331
internal static delegate* unmanaged[Cdecl]<int> Py_IsInitialized { get; }

‎src/runtime/Runtime.cs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ internal static void Shutdown()
278278
obj: true, derived: false, buffer: false);
279279
CLRObject.creationBlocked = true;
280280

281-
NullGCHandles(ExtensionType.loadedExtensions);
281+
NullGCHandles(ExtensionType.loadedExtensions.Keys);
282282
ClassManager.RemoveClasses();
283283
TypeManager.RemoveTypes();
284284
_typesInitialized = false;
@@ -351,7 +351,7 @@ static bool TryCollectingGarbage(int runs, bool forceBreakLoops,
351351
}
352352
else if (forceBreakLoops)
353353
{
354-
NullGCHandles(CLRObject.reflectedObjects);
354+
NullGCHandles(CLRObject.reflectedObjects.Keys);
355355
CLRObject.reflectedObjects.Clear();
356356
}
357357
}
@@ -618,12 +618,12 @@ internal static unsafe void XDecref(StolenReference op)
618618
[Pure]
619619
internal static unsafe nint Refcount(BorrowedReference op)
620620
{
621-
if (op == null)
622-
{
623-
return 0;
624-
}
625-
var p = (nint*)(op.DangerousGetAddress() + ABI.RefCountOffset);
626-
return *p;
621+
if (op == null) return 0;
622+
// Py_REFCNT is exported as a function on CPython 3.14+ and required on
623+
// free-threaded builds; older Pythons only expose it as a macro, so we
624+
// fall back to reading ob_refcnt directly.
625+
if (Delegates.Py_REFCNT != null) return Delegates.Py_REFCNT(op);
626+
return *(nint*)(op.DangerousGetAddress() + ABI.RefCountOffset);
627627
}
628628
[Pure]
629629
internal static int Refcount32(BorrowedReference op) => checked((int)Refcount(op));

0 commit comments

Comments
 (0)