In this blog post, Stephan Toub describes a new feature that will be included in .NET 4.6 which adds another value to the TaskCreationOptions and TaskContinuationOptions enums called RunContinuationsAsynchronously
.
He explains:
"I talked about a ramification of calling {Try}Set* methods on TaskCompletionSource, that any synchronous continuations off of the TaskCompletionSource’s Task could run synchronously as part of the call. If we were to invoke SetResult here while holding the lock, then synchronous continuations off of that Task would be run while holding the lock, and that could lead to very real problems. So, while holding the lock we grab the TaskCompletionSource to be completed, but we don’t complete it yet, delaying doing so until the lock has been released"
And gives the following example to demonstrate:
private SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
private async Task WorkAsync()
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
// work here
}
finally { _gate.Release(); }
}
Now imagine that you have lots of calls to WorkAsync:
await Task.WhenAll(from i in Enumerable.Range(0, 10000) select WorkAsync());
We've just created 10,000 calls to WorkAsync that will be appropriately serialized on the semaphore. One of the tasks will enter the critical region, and the others will queue up on the WaitAsync call, inside SemaphoreSlim effectively enqueueing the task to be completed when someone calls Release. If Release completed that Task synchronously, then when the first task calls Release, it'll synchronously start executing the second task, and when it calls Release, it'll synchronously start executing the third task, and so on. If the "//work here" section of code above didn't include any awaits that yielded, then we're potentially going to stack dive here and eventually potentially blow out the stack.
I'm having a hard time grasping the part where he talks about executing the continuation synchronously.
Question
How could this possibly cause a stack dive? More so, And what is RunContinuationsAsynchronously
effectively going to do in order to solve that problem?
The key concept here is that a task's continuation may run synchronously on the same thread that completed the antecedent task.
Let's imagine that this is
SemaphoreSlim.Release
's implementation (it's actually Toub'sAsyncSemphore
's):We can see that it synchronously completes a task (using
TaskCompletionSource
). In this case, ifWorkAsync
has no other asynchronous points (i.e. noawait
s at all, or allawait
s are on an already completed task) and calling_gate.Release()
may complete a pending call to_gate.WaitAsync()
synchronously on the same thread you may reach a state in which a single thread sequentially releases the semaphore, completes the next pending call, executes// work here
and then releases the semaphore again etc. etc.This means that the same thread goes deeper and deeper in the stack, hence stack dive.
RunContinuationsAsynchronously
makes sure the continuation doesn't run synchronously and so the thread that releases the semaphore moves on and the continuation is scheduled for another thread (which one depends on the other continuation parameters e.g.TaskScheduler
)This logically resembles posting the completion to the
ThreadPool
:i3arnon provides a very good explanation of the reasons behind introducing
RunContinuationsAsynchronously
. My answer is rather orthogonal to his; in fact, I'm writing this for my own reference as well (I myself ain't gonna remember any subtleties of this in half a year from now :)First of all, let's see how
TaskCompletionSource
'sRunContinuationsAsynchronously
option is different fromTask.Run(() => tcs.SetResult(result))
or the likes. Let's try a simple console application:Note how all continuations run synchronously on the same thread where
TrySetResult
has been called:Now what if we don't want this to happen, and we want each continuation to run asynchronously (i.e., in parallel with other continuations and possibly on another thread, in the absence of any synchronization context)?
There's a trick that could do it for
await
-style continuations, by installing a fake temporary synchronization context (more details here):Now, using
tcs.TrySetResult(true, asyncAwaitContinuations: true)
in our test code:Note how
await
continuations now run in parallel (albeit, still after all synchronousContinueWith
continuations).This
asyncAwaitContinuations: true
logic is a hack and it works forawait
continuations only. The newRunContinuationsAsynchronously
makes it work consistently for any kind of continuations, attached toTaskCompletionSource.Task
.Another nice aspect of
RunContinuationsAsynchronously
is that anyawait
-style continuations scheduled to be resumed on specific synchronization context will run on that context asynchronously (usingSynchronizationContext.Post
, even ifTCS.Task
completes on the same context (unlike the current behavior ofTCS.SetResult
).ContinueWith
-style continuations will be also be run asynchronously by their corresponding task schedulers (most often,TaskScheduler.Default
orTaskScheduler.FromCurrentSynchronizationContext
). They won't be inlined viaTaskScheduler.TryExecuteTaskInline
. I believe Stephen Toub has clarified that in the comments to his blog post, and it also can be seen here in CoreCLR's Task.cs.Why should we be worrying about imposing asynchrony on all continuations?
I usually need it when I deal with
async
methods which execute cooperatively (co-routines).A simple example is a pause-able asynchronous processing: one async process pauses/resumes the execution of another. Their execution workflow synchronizes at certain
await
points, andTaskCompletionSource
is used for such kind of synchronization, directly or indirectly.Below is some ready-to-play-with sample code which uses an adaptation of Stephen Toub's
PauseTokenSource
. Here, oneasync
methodStartAndControlWorkAsync
starts and periodically pauses/resumes anotherasync
method,DoWorkAsync
. Try changingasyncAwaitContinuations: true
toasyncAwaitContinuations: false
and see the logic being completely broken:I didn't want to use
Task.Run(() => tcs.SetResult(result))
here, because it would be redundant to push continuations toThreadPool
when they're already scheduled to run asynchronously on a UI thread with a proper synchronization context. At the same time, if bothStartAndControlWorkAsync
andDoWorkAsync
run on the same UI synchronization context, we'd also have a stack dive (iftcs.SetResult(result)
is used withoutTask.Run
orSynchronizationContext.Post
wrapping).Now,
RunContinuationsAsynchronously
is probably the best solution to this problem.