The Issue
I have an ASP.NET 4.0 WebForms page with a simple web-service WebMethod
. This method is serving as a synchronous wrapper to asynchronous / TPL code. The problem I'm facing, is that the inner Task
sometimes has a null SynchronizationContext
(my preference), but sometimes has a sync context of System.Web.LegacyAspNetSynchronizationContext
. In the example I've provided, this doesn't really cause a problem, but in my real-world development scenario can lead to dead-locks.
The first call to the service always seems to run with null sync context, the next few might too. But a few rapid-fire requests and it starts popping onto the ASP.NET sync context.
The Code
[WebMethod]
public static string MyWebMethod(string name)
{
var rnd = new Random();
int eventId = rnd.Next();
TaskHolder holder = new TaskHolder(eventId);
System.Diagnostics.Debug.WriteLine("Event Id: {0}. Web method thread Id: {1}",
eventId,
Thread.CurrentThread.ManagedThreadId);
var taskResult = Task.Factory.StartNew(
function: () => holder.SampleTask().Result,
creationOptions: TaskCreationOptions.None,
cancellationToken: System.Threading.CancellationToken.None,
scheduler: TaskScheduler.Default)
.Result;
return "Hello " + name + ", result is " + taskResult;
}
The definition of TaskHolder
being:
public class TaskHolder
{
private int _eventId;
private ProgressMessageHandler _prg;
private HttpClient _client;
public TaskHolder(int eventId)
{
_eventId = eventId;
_prg = new ProgressMessageHandler();
_client = HttpClientFactory.Create(_prg);
}
public Task<string> SampleTask()
{
System.Diagnostics.Debug.WriteLine("Event Id: {0}. Pre-task thread Id: {1}",
_eventId,
Thread.CurrentThread.ManagedThreadId);
return _client.GetAsync("http://www.google.com")
.ContinueWith((t) =>
{
System.Diagnostics.Debug.WriteLine("Event Id: {0}. Continuation-task thread Id: {1}",
_eventId,
Thread.CurrentThread.ManagedThreadId);
t.Wait();
return string.Format("Length is: {0}", t.Result.Content.Headers.ContentLength.HasValue ? t.Result.Content.Headers.ContentLength.Value.ToString() : "unknown");
}, scheduler: TaskScheduler.Default);
}
}
Analysis
My understanding of TaskScheduler.Default
is that it's the ThreadPool
scheduler. In other words, the thread won't end up on the ASP.NET thread. As per this article, "The default scheduler for Task Parallel Library and PLINQ uses the .NET Framework ThreadPool to queue and execute work". Based on that, I would expect the SynchronizationContext
inside SampleTask
to always be null.
Furthermore, my understanding is that if SampleTask
were to be on the ASP.NET SynchronizationContext
, the call to .Result
in MyWebMethod
may deadlock.
Because I'm not going "async all the way down", this is a "synchronous-on-asynchronous" scenario. Per this article by Stephen Toub, in the section titled "What if I really do need “sync over async”?" the following code should be a safe wrapper:
Task.Run(() => holder.SampleTask()).Result
According to this other article, also by Stephen Toub, the above should be functionally equivalent to:
Task.Factory.StartNew(
() => holder.SampleTask().Result,
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
Thanks to being in .NET 4.0, I don't have access to TaskCreationOptions.DenyChildAttach
, and I thought this was my issue. But I've run up the same sample in .NET 4.5 and switched to TaskCreationOptions.DenyChildAttach
and it behaves the same (sometimes grabs the ASP.NET sync context).
I decided then to go closer to the "original" recommendation, and implement in .NET 4.5:
Task.Run(() => holder.SampleTask()).Result
And this does work, in that it always has a null sync context. Which, kind of suggests the Task.Run vs Task.Factory.StartNew article has it wrong?
The pragmatic approach would be to upgrade to .NET 4.5 and use the Task.Run
implementation, but that would involve development time that I'd rather spend on more pressing issues (if possible). Plus, I'd still like to figure out what's going on with the different TaskScheduler
and TaskCreationOptions
scenarios.
I've coincidentally found that TaskCreationOptions.PreferFairness
in .NET 4.0 appears to behave as I'd wish (all executions have a null sync context), but without knowing why this works, I'm very hesitant to use it (it may not work in all scenarios).
Edit
Some extra info... I've updated my sample code with one that does deadlock, and includes some debug output to show what threads the tasks are running on. A deadlock will occur if either the pre-task or continuation-task outputs indicate the same thread id as the WebMethod.
Curiously, if I don't use ProgressMessageHandler, I don't seem able to replicate the deadlock. My impression was that this shouldn't matter, that regardless of down-stream code, I should be able to safely "wrap" an asynchronous method up in a synchronous context using the right Task.Factory.StartNew
or Task.Run
method. But this doesn't seem to be the case?
HttpClient
class to communicate with an ASP.NET Web API implementation.HttpClient
is entirely async, whether I like it or not. – Tinnitus