When TPL Task continuations are inlined
Task and Task in .NET 4.0 are absolutely awesome types and they provide the basis for async support that came in .NET 4.5. One somewhat unexpected behavior they have that can occasionally cause bugs in your code is that when a Task completes, it can execute continuations inline. In this post, I explain this and what you can do to avoid it when it becomes problematic.
First let’s explain what we mean by “executing continuations inline”. Consider this code snippet:
When executed, this output is usually produced:
OTHER TASK: awaiting task on thread 3
MAIN THREAD: About to complete task on thread 1
OTHER TASK: Resuming on thread 1
MAIN THREAD: Just returned from completing Task.
Notice in particular that the “OTHER TASK” resumes execution on the main thread, preventing the main thread from executing its own next statement until OTHER TASK yields or completes. When the main thread completed the task, TPL went through several checks including available stack space on the calling thread, and decided that some continuations should be executed inline with the caller. Sometimes it makes the opposite decision, and merely schedules these continuations to execute on their own (typically on the threadpool, or on the main thread’s main message loop).
Scheduling continuations to run on their own is generally safe. So why inline continuations at all? Speed. Scheduling work to happen on the threadpool takes time, and in a multi-threaded and/or async application that time can make a perceivable difference to the user when they pile up. With the advent of the async keyword, these continuations are often very lightweight and inlining can provide a nice boost in performance. For folks who are familiar with TaskContinuationOptions.ExecuteSynchronously, it’s important to note that this flag is merely a suggestion to inline the continuation. But continuations may or may not inline whether this flag is set or not.
What about when it is not safe to inline continuations? TPL can’t predict what a continuation will execute — it only knows a few environmental indicators such as available stack space. For instance, if the thread is already near a full stack, it’s not safe to optimistically inline a bunch of new arbitrary work so inlining continuations is less likely to happen. So what if you as the programmer know that continuations shouldn’t be inlined?
You have several options to prevent inlining continuations. Some are at the task completion source, and others are on the continuation side. Here they are, in order of most preferred to least preferred:
The most preferred solution is available if you can target .NET 4.6: Create your TaskCompletionSource with the TaskCreationOptions.RunContinuationsAsynchronously flag. This guarantees these continuations won’t be inlined.
- Use AsyncManualResetEvent from the Microsoft.VisualStudio.Threading library. This automatically prevents inlining continuations (including awaiters) and works on .NET 4.5 and later. And its API is slightly friendlier for representing events than TaskCompletionSource (since you don’t have to make up a return type and value that you don’t use).
- Call SetResult inside another Task body:
Task.Run(() => _taskCompletionSource.SetResult(0));
Inlining can still happen, but it doesn’t happen on your thread and won’t block your own method’s progress. It does mean that the Task itself may not have transitioned to its RanToCompletion state before your calling method returns, which might be a significant behavioral change for you. If that behavioral change is intolerable, you could add this line below the Task.Run line given above:
That would ensure you block until the Task transitions and thus complete before your method returns. However I’ve seen deadlocks because of this in at least one case where there was a very interesting dependency loop and set of inlined tasks. So I don’t recommend doing this.
- If you control all folks awaiting on the Task, you can
await Task.Yield();after awaiting the Task to ensure that if they are inlined, they yield it right back to prevent code to execute on the completer’s callstack.