Task continuation not working after await

2019-06-04 19:56发布

问题:

I've encountered a pretty strange case when task execution is not continued after await in IIS (not sure if it's related to IIS). I reproduced this issue using Azure Storage and following controller (full solution on github):

public class HomeController : Controller
{
    private static int _count;

    public ActionResult Index()
    {
        RunRequest(); //I don't want to wait on this task
        return View(_count);
    }

    public async Task RunRequest()
    {
        CloudStorageAccount account = CloudStorageAccount.DevelopmentStorageAccount;
        var cloudTable = account.CreateCloudTableClient().GetTableReference("test");

        Interlocked.Increment(ref _count);
        await Task.Factory.FromAsync<bool>(cloudTable.BeginCreateIfNotExists, cloudTable.EndCreateIfNotExists, null);

        Trace.WriteLine("This part of task after await is never executed");
        Interlocked.Decrement(ref _count);
    }
}

I would expect the value of _count to be always 1 (when rendered in view), but if you hit F5 several time you'll see that _count is incrementing after each refresh. That means that continuation is not called for some reason.

In fact I've lied a bit, I've noticed that continuation is called once, when Index is called for the first time. All further F5's don't decrement the counter.

If I change the method to be async:

    public async Task<ActionResult> Index()
    {
        await RunRequest(); //I don't want to wait on this task
        return View(_count);
    }

everything starts working as expected except that I don't want to keep client waiting for my asynchronous operation to finish.

So my question is: I would like to understand why this happens, and what is the consistent way to run "fire and forget" work, preferably without spanning new threads.

回答1:

what is the consistent way to run "fire and forget" work

ASP.NET was not designed for fire-and-forget work; it was designed to serve HTTP requests. When an HTTP response is generated (when your action returns), that request/response cycle is complete.

Note that ASP.NET will feel free to take down your AppDomain any time that there are no active requests. This is normally done on shared hosts after an inactivity timeout, or when your AppDomain has had a certain number of garbage collections, or every 29 hours just for no reason at all.

So you don't really want "fire and forget" - you want to produce the response but not have ASP.NET forget about it. The simple solution of ConfigureAwait(false) will cause everyone to forget about it, which means that once in a blue moon your continuation could just get "lost".

I have a blog post that goes into more detail on this subject. In short, you want to record the work to be done in a persistent layer (like an Azure table) before your response is generated. That's the ideal solution.

If you aren't going to do the ideal solution, then you're going to live dangerously. There is code in my blog post that will register Tasks with the ASP.NET runtime, so that you can return a response early but notify ASP.NET that you're not really done yet. This will prevent ASP.NET from taking down your site while you have outstanding work, but it will not protect you against more fundamental failures like a hard drive crash or someone tripping over the power cord of your server.

The code in my blog post is duplicated below; it depends on the AsyncCountdownEvent in my AsyncEx library:

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Hosting;
using Nito.AsyncEx;

/// <summary>
/// A type that tracks background operations and notifies ASP.NET that they are still in progress.
/// </summary>
public sealed class BackgroundTaskManager : IRegisteredObject
{
    /// <summary>
    /// A cancellation token that is set when ASP.NET is shutting down the app domain.
    /// </summary>
    private readonly CancellationTokenSource shutdown;

    /// <summary>
    /// A countdown event that is incremented each time a task is registered and decremented each time it completes. When it reaches zero, we are ready to shut down the app domain. 
    /// </summary>
    private readonly AsyncCountdownEvent count;

    /// <summary>
    /// A task that completes after <see cref="count"/> reaches zero and the object has been unregistered.
    /// </summary>
    private readonly Task done;

    private BackgroundTaskManager()
    {
        // Start the count at 1 and decrement it when ASP.NET notifies us we're shutting down.
        shutdown = new CancellationTokenSource();
        count = new AsyncCountdownEvent(1);
        shutdown.Token.Register(() => count.Signal(), useSynchronizationContext: false);

        // Register the object and unregister it when the count reaches zero.
        HostingEnvironment.RegisterObject(this);
        done = count.WaitAsync().ContinueWith(_ => HostingEnvironment.UnregisterObject(this), TaskContinuationOptions.ExecuteSynchronously);
    }

    void IRegisteredObject.Stop(bool immediate)
    {
        shutdown.Cancel();
        if (immediate)
            done.Wait();
    }

    /// <summary>
    /// Registers a task with the ASP.NET runtime.
    /// </summary>
    /// <param name="task">The task to register.</param>
    private void Register(Task task)
    {
        count.AddCount();
        task.ContinueWith(_ => count.Signal(), TaskContinuationOptions.ExecuteSynchronously);
    }

    /// <summary>
    /// The background task manager for this app domain.
    /// </summary>
    private static readonly BackgroundTaskManager instance = new BackgroundTaskManager();

    /// <summary>
    /// Gets a cancellation token that is set when ASP.NET is shutting down the app domain.
    /// </summary>
    public static CancellationToken Shutdown { get { return instance.shutdown.Token; } }

    /// <summary>
    /// Executes an <c>async</c> background operation, registering it with ASP.NET.
    /// </summary>
    /// <param name="operation">The background operation.</param>
    public static void Run(Func<Task> operation)
    {
        instance.Register(Task.Run(operation));
    }

    /// <summary>
    /// Executes a background operation, registering it with ASP.NET.
    /// </summary>
    /// <param name="operation">The background operation.</param>
    public static void Run(Action operation)
    {
        instance.Register(Task.Run(operation));
    }
}

It can be used like this for async or synchronous code:

BackgroundTaskManager.Run(() =>
{
    // Synchronous example
    Thread.Sleep(20000);
});
BackgroundTaskManager.Run(async () =>
{
    // Asynchronous example
    await Task.Delay(20000);
});


回答2:

Well you've got to have a thread somewhere executing the continuation. I suspect the problem is that the context captured in the awaiter "knows" that the request has already finished. I don't know the details of what happens in that situation, but it's possible that it just ignores any continuations. That does sound a little odd, admittedly...

You could try using:

await Task.Factory.FromAsync<bool>(cloudTable.BeginCreateIfNotExists,
                                   cloudTable.EndCreateIfNotExists, null)
          .ConfigureAwait(false);

That way it won't try to continue on the captured context, but instead on just an arbitrary thread-pool thread. It may not help, but it's worth a try.



回答3:

The problem is that await configures the continuation to run in the synchronization context in which it was first started. This is honestly one of the more useful aspects of the functionality, but it's a problem for you in this case. Here your sync context doesn't exist by the time the continuation is firing because you're returning the view.

My guess is that trying to access a sync context that has already been "completed" is resulting in an exception being thrown, which is why your code isn't working.

If you add ConfigureAwait(false) to the end of your FromAsync method you'll let it run in a thread pool thread, which should be fine for your case.

Other options are that an exception is being thrown from the task, or that the async operation isn't finishing at all.