Question
ThreadPool.RegisterWaitForSingleObject leaks RegisteredWaitHandle objects (and memory) over time
I build an extension method for WaitHandles (especially ManualResetEventSlim) to make them usable in async code. Usage looks like this:
public class WaitHandleExampleClass : IDisposable
{
private readonly ManualResetEventSlim _mre;
public WaitHandleExampleClass()
{
_mre = new ManualResetEventSlim(false);
}
public async Task WaitForHandle()
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
var cancelled = await _mre.WaitHandle.WaitForSignalOrCancelAsync(cts.Token)
.ConfigureAwait(false);
if (cancelled)
{
DoCancelAction();
return;
}
Proceed();
// later, _mre.Reset() is called
}
}
public void TriggerWaitHandleFromOtherThread()
{
_mre.Set();
}
public void Dispose()
{
_mre.Dispose();
}
}
My Extension method WaitForSignalOrCancelAsync() looks like this:
public static Task<bool> WaitForSignalOrCancelAsync(this WaitHandle waitHandle, CancellationToken ct)
{
if (waitHandle == null)
throw new ArgumentNullException(nameof(waitHandle));
var tcs = new TaskCompletionSource<bool>();
var task = tcs.Task;
var container = new CleanupContainer();
// dont pass ct to ContinueWith - otherwise rwh is not unregistered (even with TaskContinuationOptions.None)
task.ContinueWith(TaskContinueWith, container, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
container.Ctr = ct.Register(SetCancel, tcs, false);
container.Rwh = ThreadPool.RegisterWaitForSingleObject(waitHandle, RwhCallback, tcs, -1, true);
return task;
}
private static void SetCancel(object state) => ((TaskCompletionSource<bool>)state).TrySetResult(true);
private static void RwhCallback(object state, bool o) => ((TaskCompletionSource<bool>)state).TrySetResult(false);
private static void TaskContinueWith(Task t, object state)
{
try
{
((CleanupContainer)state).Cleanup();
}
catch (Exception e)
{
DebugAssert.ShouldNotBeCalled();
}
}
private class CleanupContainer
{
public RegisteredWaitHandle Rwh { get; set; }
public CancellationTokenRegistration Ctr { get; set; }
public void Cleanup()
{
// so this just waits until the condition is true (returns true) or timeout is reached (returns false)
var conditionOk = SpinWait.WaitFor(() => Rwh != null && Ctr != default, timeoutMs: 200);
DebugAssert.MustBeTrue(conditionOk); // always true in debug -> Rwh and Ctr are set
var rwhUnregisterOkay = Rwh?.Unregister(null);
DebugAssert.MustBeTrue(rwhUnregisterOkay == true); // also always true
Ctr.Dispose();
Rwh = null;
Ctr = default;
}
}
Atfer deployment and running this code as Windows service for multiple hours, i can see that memory usage increases. Also CPU usages increases but i am not sure if this is related. So i attached dotmemory. Over time, i get more and more objects of:
- TaskCompletionSource<bool> -> 241,890
- CancellationCallbackInfo-> 241,984
- ThreadPoolWaitOrTimerCallback -> 241,890
It does not help if i force GC with dotmemory
I guess this is related to the extension method above, but i dont get why. I took this MSDN code as base ("From Wait Handles to TAP"): https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/interop-with-other-asynchronous-patterns-and-types#see-also
I also wrote my first unit test with dotmemory integration, not sure if its correct or helpful. But with that i know that Rwh.Unregister() makes a different as the object count different if its commented out in the Cleanup method. Its red because registeredWaitHandles.ObjectsCount == 1, not 0
[TestMethod]
[DotMemoryUnit(FailIfRunWithoutSupport = false)]
public async Task WaitForSignalOrCancelAsync_ResourcesAreReleased()
{
using (var waitHandle = new ManualResetEventSlim(false))
{
var cancellationTokenSource = new CancellationTokenSource();
var task = waitHandle.WaitHandle.WaitForSignalOrCancelAsync(cancellationTokenSource.Token);
cancellationTokenSource.Cancel();
await task;
for (var i = 0; i < 10; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Thread.Sleep(200);
for (var i = 0; i < 10; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
dotMemory.Check(memory =>
{
var registeredWaitHandles = memory.GetObjects(where => where.Type.Is<RegisteredWaitHandle>());
// if i comment out the Rwh.Unregister() in CleanupContainer.Cleanup() i get 2 as count here
// with Rwh.Unregister(), i get 1 here
registeredWaitHandles.ObjectsCount.MustBeEqualTo(0, "rwh > 1");
var cancellationTokenRegistrations = memory.GetObjects(where => where.Type.Is<CancellationTokenRegistration>());
cancellationTokenRegistrations.ObjectsCount.MustBeEqualTo(0, "ctr > 0");
});
}
}
Edit: I tried the solution from Ivan (stackoverflow link) but i run into the same problem. My unit test is still red with 2 remaining references I briefly checked it on the server, after 20 minutes i see increasing new objects of the 3 types (TaskCompletionSource, ...)
I quickly tried AsyncEx, but with that my unit test is also red with that
Code i tried:
public static async Task<bool> WaitForSignalOrCancelAsync(this WaitHandle waitHandle, CancellationToken cancellationToken, int timeoutMilliseconds = Timeout.Infinite)
{
try
{
await waitHandle.WaitOneAsync(cancellationToken, timeoutMilliseconds).ConfigureAwait(false);
return false; // no cancel
}
catch (OperationCanceledException)
{
return true; // cancel
}
}
private static Task WaitOneAsync(this WaitHandle waitHandle, CancellationToken cancellationToken, int timeoutMilliseconds = Timeout.Infinite)
{
if (waitHandle == null)
throw new ArgumentNullException(nameof(waitHandle));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
CancellationTokenRegistration ctr = cancellationToken.Register(() => tcs.TrySetCanceled());
TimeSpan timeout = timeoutMilliseconds > Timeout.Infinite ? TimeSpan.FromMilliseconds(timeoutMilliseconds) : Timeout.InfiniteTimeSpan;
RegisteredWaitHandle rwh = ThreadPool.RegisterWaitForSingleObject(waitHandle,
(_, timedOut) =>
{
if (timedOut)
{
tcs.TrySetCanceled();
}
else
{
tcs.TrySetResult(true);
}
},
null, timeout, true);
Task<bool> task = tcs.Task;
_ = task.ContinueWith(_ =>
{
var ok = rwh.Unregister(null);
var ok2 = rwh.Unregister(waitHandle);
ctr.Dispose();
}, CancellationToken.None);
return task;
}