Skip to content

Avoid GC deadlock under Wine: add GC safe point to Dispatcher.ForBatched busy-wait - #3600

Open
pm-lucarohde wants to merge 4 commits into
stride3d:masterfrom
pm-lucarohde:fix/wine-gc-forbatched-busywait
Open

pm-lucarohde wants to merge 4 commits into
stride3d:masterfrom
pm-lucarohde:fix/wine-gc-forbatched-busywait

Conversation

@pm-lucarohde

@pm-lucarohde pm-lucarohde commented Oct 15, 2026 •

Copy link
Copy Markdown

PR Details

Dispatcher.ForBatched busy-waits on ThreadPool.Instance.TryCooperate() until all batches
are done. While the work queue is empty, this loop never makes a system call. Under
Wine/Proton with .NET 9+, CoreCLR suspends threads for GC with special user-mode APCs
(QueueUserAPC2), and Wine doesn't deliver those to such a thread. So a GC that starts during
this wait deadlocks the process. In Game Studio, this freezes the editor ~30–45 s after
opening a project.

When running under Wine, the loop now also calls a trivial regular P/Invoke
(kernel32!GetCurrentThreadId) each iteration. The call goes through preemptive mode, so a
pending GC can proceed and the thread waits for it on return. It never yields the time slice.
Wine is detected once (ntdll!wine_get_version) into a static readonly bool, so tiered JIT
removes the check on other platforms. The busy-wait itself is unchanged.

Testing: with the real Stride.Core under GE-Proton11-6 / unpatched .NET 10.0.13, master
hangs 3/3 and this branch passes 3/3. Natively on Linux it passes too (details in the
comments).

Related Issue

Fixes #3599
Related: #3419

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My change requires a change to the documentation.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

…r Wine

While waiting for other batches, ForBatched spun on TryCooperate() without
ever making a system call. Since .NET 9, CoreCLR suspends threads for GC via
special user-mode APCs (QueueUserAPC2), which Wine/Proton does not deliver to
such a thread, so a GC starting during the wait deadlocked Game Studio.

Use SpinWait when there is nothing to steal; it yields after a short spin,
which lets the GC suspend the thread. sleep1Threshold: -1 avoids Sleep(1).

Fixes stride3d#3599

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pm-lucarohde

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@Eideren Eideren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thread running this method is often (not always) the main thread. We definitely can spinwait, but we can't yield the timeslice here; games run on busywait or vblank loops, having a single thread hammered 24/7 helps reduce discrepancies in frame to frame interval.

Now this is definitely an issue that's not just localized around your specific setup as the gamestudio's threads in general do not necessarily follow this ethos. It spawns a ton of games and they all compete for the same resources with little to no orchestration.
I would like to improve that point, but there are more pressing tasks right now.

This change definitely introduces performance regressions, not as a constant cost, but one of stability under load, I would rather not accept it as is. Couple areas worth investigating instead:

  • A non-yielding native method the GC can interrupt in your setup, seems unlikely but might as well validate that
  • Listen for GC suspensions and let it yield then, same as above
  • Detect this specific setup and add a specific path for it, preferably something that can be JITed away when running outside of that path.

The SpinWait back-off yielded the time slice, which is not acceptable here:
the waiting thread is often the main thread and must keep spinning.

Instead, keep the original busy-wait and, only when running under Wine,
call a trivial regular P/Invoke (GetCurrentThreadId) each iteration. The
P/Invoke round-trips through preemptive mode, so a pending GC can proceed
without the special user-mode APC that Wine does not deliver, and the
thread never yields. Wine is detected once via ntdll's wine_get_version
export into a static readonly field, so tiered JIT removes the check on
other platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pm-lucarohde
pm-lucarohde force-pushed the fix/wine-gc-forbatched-busywait branch from a431c1f to d5bec23 Compare October 16, 2026 15:59
@pm-lucarohde

Copy link
Copy Markdown
Author

@Eideren

Thanks, makes sense. I pushed d5bec23, which combines 1 and 3: the original busy-wait is back and never yields.

1. Native call: works. A regular P/Invoke (GetCurrentThreadId, no [SuppressGCTransition]) goes through preemptive mode, so the GC can proceed without the APC, and the thread waits for it on return. No syscall, no yield, and the cost is within noise (~5 ns/iteration with and without it, under Wine). Thread.SpinWait would work too, but it adds a ~33 ns pause on every platform.

2. Listening for GC suspensions: not possible, as far as I can tell. Nothing in managed code exposes a pending suspension. The P/Invoke return in 1 is exactly that check, just done by the runtime.

3. Wine-only path: works. Wine is detected via ntdll!wine_get_version into a static readonly bool. Outside Wine, the Tier1 code has no trace of it (checked with DOTNET_JitDisasm).

Test with the real Stride.Core (GE-Proton11-6, unpatched .NET 10.0.13): Dispatcher.For in a loop while another thread forces GCs. master hangs 3/3, d5bec23 passes 3/3. Natively on Linux it passes too.

@pm-lucarohde
pm-lucarohde requested a review from Eideren October 16, 2026 16:16

@Eideren Eideren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks!

// also helps prevent potential deadlocks from badly threaded code
while (Volatile.Read(ref batch.ItemsDone) < batch.Total && Volatile.Read(ref batch.ExceptionThrown) == null)
{
ThreadPool.Instance.TryCooperate();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it test if TryCooperate() returned false before doing the Poll()?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it test if TryCooperate() returned false before doing the Poll()?

Good point! I’ve revised it.

@pm-lucarohde pm-lucarohde changed the title Avoid GC deadlock under Wine: back off in Dispatcher.ForBatched busy-wait Avoid GC deadlock under Wine: add GC safe point to Dispatcher.ForBatched busy-wait Oct 25, 2026
@Kryptos-FR

Copy link
Copy Markdown
Member

The Wine hang seems fixed in Wine 11. Nevertheless, I think this small fix doesn't hurt and can prevent any other future regression.

@MsEpsilon

Copy link
Copy Markdown
Contributor

I'm having freezes when the GameEditor is building thumbnails (stuck at 247 in queue, with a empty project with all assets packs installed) despite running Wine 11.0.

@xen2 xen2 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MsEpsilon your freeze still happen with this branch?
If yes, one candidate: RootRenderFeature.GetOrCreateObjectNode (around line 155) has an empty while (Volatile.Read(...) == -2) { } spin. The thread it waits on calls objectNodes.Add, which can allocate and trigger a GC, so it could hit the same problem. Fine as a follow-up.

Comment thread sources/core/Stride.Core/Threading/WineGCSafePoint.cs Outdated
@xen2 xen2 added ci-enduser Run the end-user sample screenshot + packaging suite on this PR ci-editor Run the GameStudio editor screenshot suite on this PR labels Oct 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-editor Run the GameStudio editor screenshot suite on this PR ci-enduser Run the end-user sample screenshot + packaging suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Game Studio freezes under Wine/Proton: GC cannot suspend Dispatcher.ForBatched busy-wait (.NET 9+)

5 participants