Skip to content

Commit 4ec6ca1

Browse files
Pin PyThreadStates so native extensions can cache them per thread (#137)
* Pin PyThreadStates so native extensions can cache them per thread Py.GIL() acquires the GIL via PyGILState_Ensure/Release; the outermost scope on a .NET thread creates a fresh PyThreadState and deletes it on dispose. pybind11-based extensions (matplotlib >= 3.10 _path/ft2font, scipy, PyTorch) cache the first PyThreadState* they see per OS thread in pybind11 internals TLS and never invalidate it, so once a scope deletes the thread state the next native GIL acquire on that thread restores a dangling pointer: 0xC0000005 access violation or heap corruption. Root cause of QuantConnect/Lean#9203 (ReportChartTests crashing the test host on the first matplotlib render). Pin each thread state with one extra never-released PyGILState_Ensure per thread per runtime run, keeping the gilstate counter >= 1 so the thread state lives until engine shutdown - the same lifetime CPython gives thread states it binds itself. The extra Ensure runs with the GIL already held (a bare counter increment): GIL acquire/release timing is unchanged and empty-scope microbenchmarks are within noise (0.212 -> 0.209 us/scope). Regression test: threading.local values live in the thread state dict, so a value stored in one Py.GIL scope survives a second scope on the same thread only if the thread state was not deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update version to 2.0.62 Bump package <Version>, AssemblyVersion/AssemblyFileVersion and the perf-test baseline reference to 2.0.62. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 687791d commit 4ec6ca1

5 files changed

Lines changed: 74 additions & 5 deletions

File tree

src/embed_tests/TestGILState.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
namespace Python.EmbeddingTest
22
{
3+
using System;
4+
using System.Threading;
35
using NUnit.Framework;
46
using Python.Runtime;
57

@@ -18,6 +20,57 @@ public void CanDisposeMultipleTimes()
1820
}
1921
}
2022

23+
/// <summary>
24+
/// The thread's PyThreadState must survive between GIL scopes. Native extensions
25+
/// built with pybind11 (e.g. matplotlib >= 3.10 _path/ft2font) cache the pointer
26+
/// per OS thread and crash with an access violation if a later scope runs after
27+
/// the thread state was deleted. threading.local values live in the thread state
28+
/// dictionary, so they survive a second scope only if the thread state did.
29+
/// </summary>
30+
[Test]
31+
public void ThreadStateIsPreservedBetweenGILScopes()
32+
{
33+
var result = 0;
34+
Exception error = null;
35+
var thread = new Thread(() =>
36+
{
37+
try
38+
{
39+
PyModule scope;
40+
using (Py.GIL())
41+
{
42+
scope = Py.CreateScope();
43+
scope.Exec("import threading\nlocal = threading.local()\nlocal.value = 42");
44+
}
45+
using (Py.GIL())
46+
using (scope)
47+
{
48+
using var value = scope.Eval("getattr(local, 'value', -1)");
49+
result = value.As<int>();
50+
}
51+
}
52+
catch (Exception e)
53+
{
54+
error = e;
55+
}
56+
});
57+
// the fixture initializes the engine with the GIL held on this thread:
58+
// release it so the worker thread can acquire it
59+
var ts = PythonEngine.BeginAllowThreads();
60+
try
61+
{
62+
thread.Start();
63+
thread.Join();
64+
}
65+
finally
66+
{
67+
PythonEngine.EndAllowThreads(ts);
68+
}
69+
70+
Assert.IsNull(error);
71+
Assert.AreEqual(42, result);
72+
}
73+
2174
[OneTimeSetUp]
2275
public void SetUp()
2376
{

src/perf_tests/Python.PerformanceTests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1414
</PackageReference>
1515
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
16-
<PackageReference Include="quantconnect.pythonnet" Version="2.0.61" GeneratePathProperty="true">
16+
<PackageReference Include="quantconnect.pythonnet" Version="2.0.62" GeneratePathProperty="true">
1717
<IncludeAssets>compile</IncludeAssets>
1818
</PackageReference>
1919
</ItemGroup>
@@ -25,7 +25,7 @@
2525
</Target>
2626

2727
<Target Name="CopyBaseline" AfterTargets="Build">
28-
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.61\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
28+
<Copy SourceFiles="$(NuGetPackageRoot)quantconnect.pythonnet\2.0.62\lib\net10.0\Python.Runtime.dll" DestinationFolder="$(OutDir)baseline" />
2929
</Target>
3030

3131
<Target Name="CopyNewBuild" AfterTargets="Build">

src/runtime/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
[assembly: InternalsVisibleTo("Python.EmbeddingTest, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
55
[assembly: InternalsVisibleTo("Python.Test, PublicKey=00240000048000009400000006020000002400005253413100040000110000005ffd8f49fb44ab0641b3fd8d55e749f716e6dd901032295db641eb98ee46063cbe0d4a1d121ef0bc2af95f8a7438d7a80a3531316e6b75c2dae92fb05a99f03bf7e0c03980e1c3cfb74ba690aca2f3339ef329313bcc5dccced125a4ffdc4531dcef914602cd5878dc5fbb4d4c73ddfbc133f840231343e013762884d6143189")]
66

7-
[assembly: AssemblyVersion("2.0.61")]
8-
[assembly: AssemblyFileVersion("2.0.61")]
7+
[assembly: AssemblyVersion("2.0.62")]
8+
[assembly: AssemblyFileVersion("2.0.62")]

src/runtime/Py.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,28 @@ public void Dispose()
2828

2929
public class GILState : IDisposable
3030
{
31+
// Tracks the runtime run for which this thread's PyThreadState has been pinned.
32+
// Native extensions built with pybind11 (e.g. matplotlib >= 3.10 _path/ft2font)
33+
// cache the PyThreadState pointer per OS thread and reuse it later; if the
34+
// outermost PyGILState_Release deletes the thread state, that cached pointer
35+
// dangles and the next native GIL acquire crashes with an access violation.
36+
// Pinning: one extra, never-released PyGILState_Ensure per thread keeps the
37+
// gilstate counter >= 1 so the thread state lives until engine shutdown.
38+
[ThreadStatic] private static int _pinnedOnRun;
39+
3140
private readonly PyGILState state;
3241
private bool isDisposed;
3342

3443
internal GILState()
3544
{
3645
state = PythonEngine.AcquireLock();
46+
47+
var run = Runtime.GetRun();
48+
if (_pinnedOnRun != run)
49+
{
50+
_pinnedOnRun = run;
51+
Runtime.PyGILState_Ensure();
52+
}
3753
}
3854

3955
public virtual void Dispose()

src/runtime/Python.Runtime.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<RootNamespace>Python.Runtime</RootNamespace>
66
<AssemblyName>Python.Runtime</AssemblyName>
77
<PackageId>QuantConnect.pythonnet</PackageId>
8-
<Version>2.0.61</Version>
8+
<Version>2.0.62</Version>
99
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
1010
<PackageLicenseFile>LICENSE</PackageLicenseFile>
1111
<RepositoryUrl>https://github.com/pythonnet/pythonnet</RepositoryUrl>

0 commit comments

Comments
 (0)