Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by CosmicSeeker486

Why does ManualResetEventSlim.Set hang when suspending a waiting thread in my C# test?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

I have a tool that finds and resumes suspended threads in a specific process. To test it, I wrote a unit test that starts a thread which waits on a ManualResetEventSlim. Here’s the original test using a busy wait on threadId:

CSHARP
// Arrange var timeout = TimeSpan.FromSeconds(10); var locker = new object(); var suspendEvent = new ManualResetEventSlim(false); ulong threadId = 0; var thread = new Thread(() => { lock (locker) { threadId = InteropAPI.TRGetCurrentThreadId(); } Log($"Thread started: {threadId}"); suspendEvent.Wait(); Log($"Thread finished: {threadId}"); }); thread.Start(); for (var i = 0; i < 100; i++) { lock (locker) { if (threadId != 0) { break; } } Thread.Sleep(10); } Assert.That(threadId, Is.Not.EqualTo(0)); // Act Log("Suspending thread..."); InteropAPI.TRSuspendThread(threadId); Log("Thread suspended"); suspendEvent.Set(); Log("Event set"); // Assert part omitted

The call TRGetCurrentThreadId simply wraps the WinAPI GetCurrentThreadId and TRSuspendThread calls SuspendThread. This version works reliably even when executed thousands of times.

I then refactored the test to eliminate the waiting loop, aiming to remove the need for the threadId wait hack:

CSHARP
// Arrange var timeout = TimeSpan.FromSeconds(10); var locker = new object(); var suspendEvent = new ManualResetEventSlim(false); var threadSetEvent = new ManualResetEventSlim(false); ulong threadId = 0; var thread = new Thread(() => { lock (locker) { threadId = InteropAPI.TRGetCurrentThreadId(); } threadSetEvent.Set(); Log($"Thread started: {threadId}"); suspendEvent.Wait(); Log($"Thread finished: {threadId}"); }); thread.Start(); threadSetEvent.Wait(timeout); Assert.That(threadId, Is.Not.EqualTo(0)); // Act Log("Suspending thread..."); InteropAPI.TRSuspendThread(threadId); Log("Thread suspended"); suspendEvent.Set(); Log("Event set");

The issue is that in the refactored version the execution gets stuck at suspendEvent.Set(). The logs show the following:

Thread started: 37348
Suspending thread...
Thread suspended

Although the thread is suspended as confirmed via Process Explorer, manually resuming it lets the test finish and print:

Event set
Thread finished: 37348

The questions are as follows:

  1. Why does execution stop at suspendEvent.Set()? Does Set() depend on the waiting thread being active to complete its internal signaling?

  2. Why does the initial version work while the refactored version deadlocks? Is it simply a subtle timing issue that avoids acquiring a lock held by the suspended thread?

Additional remarks:

  • Tests are never run in parallel
  • The test runs in Unity, which uses a custom version of Mono

I’m looking for an explanation of the deadlock behavior and any insights into the timing differences that cause the initial “hack” to work correctly. Any help to better understand and resolve this issue is appreciated.

1

Comments 0

Answers 2

Views 89

2 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

Below is an explanation with sample code and notes on why the behavior differs between the two tests.


1. Why does the execution stop at suspendEvent.Set()?

Although the design of a ManualResetEventSlim does not require that its waiting party be running when Set() is called, the problem here comes from an internal detail of the event’s implementation. When a thread calls Wait() on a ManualResetEventSlim, it may hold an internal lock (or be in the middle of an unsignaled spin–then–blocking transition) that is used by the Set() method to signal waiting threads. In your refactored test, the worker thread is suspended via SuspendThread while it is waiting on the event. If the worker thread is suspended in the middle of its wait routine – before it has fully released any internal locks or completed the transition of the wait state – then the call to suspendEvent.Set() will eventually try to acquire that internal lock. However, since the suspended thread is not running (and therefore not releasing the lock), the call to Set() cannot complete. In other words, the underlying synchronization primitive is blocked on a lock that the suspended thread holds.


2. Why does the initial version work while the refactored one gets stuck?

In your first version you used an ad hoc loop (or “wait hack”) that repeatedly checked for a nonzero threadId under a lock. This approach incidentally delayed the moment when you suspended the thread. In that version, by the time you called TRSuspendThread the worker thread had already advanced beyond acquiring any locks in its call to suspendEvent.Wait(). That is, the worker thread reached the stable “waiting” state (or had released any helper locks) before suspension. Consequently, the call to suspendEvent.Set() could complete because no internal lock was held.

In contrast, your refactored version signals immediately (via threadSetEvent) that the worker thread has started and assigns the thread ID. But even though the thread’s action started and recorded its ID, the suspension happens so early that the worker thread is likely still partway through entering the wait (or holding an internal lock in the wait logic). The subtle timing difference causes the suspended thread to be “in flight” with its synchronization, which leads the Set() call to deadlock.


Summary

  • ManualResetEventSlim and Suspension: The Set() method of ManualResetEventSlim must acquire an internal lock to update its state and release waiting threads. If a waiting thread is suspended while holding that lock, the call to Set() blocks.
  • Timing Matters: In the initial version, the worker thread had time to fully enter its waiting state and release any locks before being suspended. In the refactored version, the immediate suspension occurs before that lock is released, causing Set() to hang.

When testing or using thread synchronization primitives, be aware that suspending a thread while it is in the middle of an internal synchronization operation (even if it is “just waiting”) can lead to deadlock situations like this.


This explanation relates directly to your tested scenarios in Unity (using a custom Mono runtime) and explains the observed differences without exposing extra internal implementation details.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by MeteorPioneer097 1 month ago

0

I think that threadSetEvent.Set() introduces a race between the calls to suspendEvent.Wait() and suspendEvent.Set().

When your test thread calls suspendEvent.Set();, it tries to acquire the m_lock of the ManualResetEventSlim (source code).

CSHARP
private void Set(bool duringCancellation) { IsSet = true; if (Waiters > 0) { lock (m_lock) { Monitor.PulseAll(m_lock); } }

However, the suspended thread might have already acquired this lock with the call to Wait() before it was suspended:

CSHARP
lock (m_lock) { while (!IsSet) { Waiters++; if (IsSet) // This check must occur after updating Waiters. { Waiters--; // revert the increment. return true; } try { // ** the actual wait ** if (!Monitor.Wait(m_lock, realMillisecondsTimeout)) return false; // return immediately if the timeout has expired. } finally { // Clean up: we're done waiting. Waiters--; } } }

The call to Monitor.Wait() will release the lock, so maybe the thread is suspended before the call to it - causing the dead-lock. When you resume the thread it releases it, so the event can be set by the test thread.

I think you can't really remove the need for "a hack" in that you might need to non-blockingly wait, but maybe make things a bit cleaner with the use of a Barrier because it's SignalAndWait doesn't take a lock at least on Signal(Set) part.:

CSHARP
// Arrange var timeout = TimeSpan.FromSeconds(10); var threadSetEvent = new ManualResetEventSlim(false); var barrier = new Barrier(2); uint threadId = 0; var thread = new Thread(() => { threadId = InteropAPI.GetCurrentThreadId(); threadSetEvent.Set(); Console.WriteLine($"Thread started: {threadId}"); barrier.SignalAndWait(); Console.WriteLine($"Thread finished: {threadId}"); }); thread.Start(); threadSetEvent.Wait(timeout); // Act Console.WriteLine("Suspending thread..."); var spinWait = new SpinWait(); // make sure SignalAndWait() is called from the other thread // AND in a Waiting state // before we Suspend it while (barrier.ParticipantsRemaining != 1) { // maybe also add || thread.ThreadState == ThreadState.WaitSleepJoin spinWait.SpinOnce(); } InteropAPI.SuspendThreadById(threadId); Console.WriteLine("Thread suspended"); // this unblocks the other thread // which when resumed will be able to proceed barrier.SignalAndWait(); Console.WriteLine("Event set"); InteropAPI.ResumeThreadById(threadId);

No comments yet.

Discussion

No comments yet.