While developing some asynchronous code my colleagues and I came across a strange problem with one portion of our code. A component was triggered at a continuous rate, but we wanted one of its dependencies that it called to never be executing more than one task at a time.
We decided to just cache the task, no problem. We threw a lock around the assignment of the cached task and had the singleton task remove itself once it completed.
Task onlyOneTask;
public Task TryToBeginSomeProcess()
{
lock (someLock)
{
if (onlyOneTask == null)
{
onlyOneTask = DoSomethingButOnlyOneAtATime();
}
return onlyOneTask;
}
}
public async Task DoSomethingButOnlyOneAtATime()
{
// do some work
await SomeWork();
lock (someLock)
{
onlyOneTask = null;
}
}
So, this actually seems to work... assuming that something in the SomeWork() call actually yields. However, if some work looks like this:
public Task SomeWork()
{
return Task.FromResult(0);
}
It fails! Subtly so, however. The line that clears the onlyOneTask executes BEFORE the task is assigned! Afterwards there is always a task instance in onlyOneTask and it never re-executes the work.
You might think that this should deadlock, but threads may reacquire locks, and since asynchronous code may downgrade itself to fully synchronous code, the second lock is acquired on the same thread. No deadlock.
If SomeWork yields at some point in its execution then it seems to behave as we expect, but it does raise some questions about its exact behavior. I am unsure about the exact details of how threads and locks interact when asynchronous code executes synchronously versus asynchronously.
Another thing I'd like to point out is that this could seemingly be fixed by the usage of Task.Run to force the work to run on its own task. There's a major problem with this: This code was intended to run in a .NET service, and it's bad form to use Task.Run there.
One way to seemingly fix this is if there were some way to force a Task to always behave like it had an asynchronous implementation. I imagine that it doesn't exist, but I might as well ask.
So, onto the question asking:
- Can this scenario deadlock even if SomeWork() yields?
- Can the asynchronous case behave like the synchronous case (clear onlyOneTask before it is set) if the TaskScheduler continues on the same thread and SomeWork() is fast enough?
- Is there a way to force an existing Task to always behave as though it were asynchronous without compromising the thread pool?
- Is there a better alternative?
For what it's worth we found if we just inspected the status of the cached task to determine when it was appropriate to initiate SomeWork() again our issues disappeared, so this is mostly academic in nature.