Tripping on the TPL
I thought that I found a bug in the TPL, but it looks like its working (more or less) by design. Basically, when a task is completed, all awaiting tasks will be notified on that, which is pretty much what you would expect. What isn’t usually expected is that those tasks can interfere with one another. Consider the following code:
We have two tasks, which accept a parent task and do something with it. What do you think will happen when we run the following code?
Unless you are very quick on the draw, running this code will result in a timeout message, but how? We know that we have a much shorter duration for the task than the timeout, so what is going on?
Well, effectively what is going on is that the parent task has a list of children that it will notify, and by default, it will do so synchronously and sequentially. If a child task blocks for whatever reason (for example, it might be processing a lot of work), the other children of the parent task will not be notified.
If there is a timeout setup, it will be triggered, even though the parent task was already completed. It took us a lot of time to figure out the repro in this issue, and we were certain that this is some sort of race condition in the TPL. I had a blog post talking all about it, but the Microsoft team is fast enough that they were able to literally answer my issue before I had the time to complete my blog post. That is really impressive.
I should note that the suggestion, using RunContinuationsAsynchronously, works quite well for creating a new Task or using TaskCompletionSource, but there is no way to specify that when you are using Task.Run. What is worse for us is that since this is not the default (for perfectly good performance reasons, by the way), this means that any code that we call into might trigger this. I would have much rather to be able to specify than when waiting on the task, rather than when creating it.
Comments
"I would have much rather to be able to specify than when waiting on the task, rather than when creating it" Agree, the decision on how to run continuations should be at the caller. If the task comes from a library then the caller has no option to run the continuations asynchronously, meaning it can't protect the code from potentially long running continuations (without ugly workarounds, when feasible.)
There are arguments for both sides really. It may be necessary for the callee to specify the continuation behaviour in order to correctly adhere to its contract (synchronous callbacks), or to protect itself from caller-supplied continuations which may block.
Other considerations:
There are issues with consuming third-party library code which irresponsibly attaches long-running continuations prior to returning the Task, but I'd usually consider that a bug in the library. The framework's async feature caters to application devs and deliberately pushes the burden of understanding this stuff onto the library dev (see also:
.ConfigureAwait(false)
), which IMO is a reasonable tradeoff when you can't please both sides.I agree, you should really be able to specify/override this behavior when registering a continuation/callback.
There is a solution to the Task.Run quandary though: instead of using Task.Run, you can use Task.Factory.StartNew. Then you can specify TaskCreationOptions.RunContinuationsAsynchronously.
Note: to get the same behavior as Task.Run you need to set other parameters too. https://blogs.msdn.microsoft.com/pfxteam/2011/10/24/task-run-vs-task-factory-startnew/
Task.Run(someAction);
is equivalent to:
Task.Factory.StartNew(someAction, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
I also reported this as a bug when I came across it: http://blog.stephencleary.com/2012/12/dont-block-in-asynchronous-code.html
As a clarification, task continuations do not run synchronously by default. However, await (more specifically, TaskAwaiter) passes TaskContinuationOptions.RunSynchronously, so it is explicitly requesting synchronous continuations. Most of the time, this works as a performance optimization and does not cause problems. https://github.com/Microsoft/referencesource/blob/4fe4349175f4c5091d972a7e56ea12012f1e7170/Microsoft.Bcl.Async/Microsoft.Threading.Tasks/Runtime/CompilerServices/TaskAwaiter.cs#L129
Interestingly, JavaScript has taken the opposite approach: promise continuations (and thus
await
) are always executed asynchronously, even if the promise has already completed. While I find the always-asynchronous approach to be the more natural and expected semantics, that decision has also caused it own confusion (some devs assume that callingthen
on a completed promise will execute the code synchronously).Stephen Cleary, that's a very interesting observation. if the continuation is made to run synchronously in the awaiter implementation, and awaiters in C# are pattern based, then perhaps there is a way to replace the default task awaiter with a custom one.
I'm thinking right now of creating a new Task library with forwarded types for the Task types and new custom awaiter implementation.
Scratch that, the GetAwaiter() method was made an instance method on Task class it appears ... so can't trick the compiler with an extension method.
This is actually a special case of a very pervasive inlining policy in the TPL. It's a design bug. See https://github.com/dotnet/corefx/issues/2454 Remove reentrancy by default from the TPL.
Generally, this should be what you want in your particular project since it saves CPU time. For 99% of applications it is not what you want.
Completing a task can therefore synchronously invoke arbitrary code. How horrible! Reentrancy is terribly hard to code for.
That's why you can't usually complete a task while taking a lock. You will pull arbitrary code into the lock.
Comment preview