I'm playing with some threading constructs in C#, and ran into something that defies my understanding of how locking works. I have a helper function that accepts an asynchronous task, and uses a TaskCompletionSource
member to try and synchronize access when called multiple times.
public static void Main(string[] args)
{
var test = new TestClass();
var task1 = test.Execute("First Task", async () => await Task.Delay(1000));
var task2 = test.Execute("Second Task", async () => await Task.Delay(1000));
task1.Wait();
task2.Wait();
Console.ReadLine();
}
class TestClass : IDisposable
{
private readonly object _lockObject = new object();
private TaskCompletionSource<bool> _activeTaskCompletionSource;
public async Task Execute(string source, Func<Task> actionToExecute)
{
Task activeTask = null;
lock (_lockObject)
{
if (_activeTaskCompletionSource != null)
{
activeTask = _activeTaskCompletionSource.Task;
}
else
{
_activeTaskCompletionSource = new TaskCompletionSource<bool>();
}
}
while (activeTask != null)
{
await activeTask;
lock (_lockObject)
{
if (_activeTaskCompletionSource != null)
{
activeTask = _activeTaskCompletionSource.Task;
}
else
{
activeTask = null;
}
}
}
await actionToExecute();
lock (_lockObject)
{
_activeTaskCompletionSource.SetResult(true);
_activeTaskCompletionSource = null;
}
}
}
This always ends up falling into an infinite loop for the second task. I put some code to log each step as it happens, and it always produces something like this (I've manually inserted comments after #s):
[First Task] Waiting for lock (setup) [First Task] Entered lock (setup) [First Task] Grabbing '_activeTaskCompletionSource' (setup) [First Task] Lock released (setup) [First Task] RUNNING ... [Second Task] Waiting for lock (setup) [Second Task] Entered lock (setup) [Second Task] Assigning 'activeTask' (setup) [Second Task] Lock released (setup) [Second Task] Waiting for task to complete ... [First Task] COMPLETED! [First Task] Waiting for lock (cleanup) [First Task] Entered lock (cleanup) [First Task] Setting _activeTaskCompletionSource result ... # Never gets to '_activeTaskCompletionSource = null' # Never gets to 'Releasing lock (cleanup)' for first task [Second Task] Awaited task completed! [Second Task] Waiting for lock (loop) # Immediately enters lock after 'await' is complete # Does not wait for 'First Task' to finish its lock! [Second Task] Entered lock (loop) [Second Task] Assigning 'activeTask' (loop) [Second Task] Lock released (loop) [Second Task] Waiting for task to complete ... [Second Task] Awaited task completed!
This ultimately sends the second task into an infinite loop, because _activeTaskCompletionSource
is never set back to null
.
I was under the impression that no other thread could ever enter a lock until all previous threads had exited it, but here, my First Task
thread never finishes and releases its cleanup lock before the Second Task
thread grabs hold of it.
Does this have anything to do with mixing locks and async/await?